From c43ab28a513a5b7d9f92808638090a75a634b442 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Wed, 8 Apr 2026 13:48:09 +0200 Subject: [PATCH 01/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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 61db06c5d6ec78beda55d872fedfd4234cac9e1b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 07:57:56 -0300 Subject: [PATCH 09/60] feat: port price widgets related screens to figma V61 --- Bitkit/Components/Widgets/PriceWidget.swift | 226 +++++++-------- Bitkit/MainNavView.swift | 7 +- Bitkit/Models/PriceWidgetOptions.swift | 4 +- .../Localization/en.lproj/Localizable.strings | 9 + Bitkit/Utilities/WidgetsBackupConverter.swift | 5 +- .../Widgets/PriceWidgetPreviewView.swift | 263 ++++++++++++++++++ Bitkit/Views/Widgets/WidgetEditItemView.swift | 38 ++- Bitkit/Views/Widgets/WidgetEditLogic.swift | 21 +- Bitkit/Views/Widgets/WidgetEditModels.swift | 83 +++--- Bitkit/Views/Widgets/WidgetEditView.swift | 14 +- BitkitWidget/PriceHomeScreenWidget.swift | 220 +++++++-------- changelog.d/next/price-widget-v61.changed.md | 1 + 12 files changed, 563 insertions(+), 328 deletions(-) create mode 100644 Bitkit/Views/Widgets/PriceWidgetPreviewView.swift create mode 100644 changelog.d/next/price-widget-v61.changed.md diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index 8da348dab..a5935567e 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -1,21 +1,14 @@ import Charts import SwiftUI -/// A widget that displays cryptocurrency price information with chart +/// Displays Bitcoin price for the user's selected trading pair and timeframe (Figma v61). struct PriceWidget: View { - /// Configuration options for the widget var options: PriceWidgetOptions = .init() - - /// Flag indicating if the widget is in editing mode var isEditing: Bool = false - - /// Callback to signal when editing should end var onEditingEnd: (() -> Void)? - /// Price view model singleton @StateObject private var viewModel = PriceViewModel.shared - /// Initialize the widget init( options: PriceWidgetOptions = PriceWidgetOptions(), isEditing: Bool = false, @@ -32,91 +25,121 @@ struct PriceWidget: View { isEditing: isEditing, onEditingEnd: onEditingEnd ) { - VStack(spacing: 0) { - if viewModel.isLoading && filteredPriceData.isEmpty { - WidgetContentBuilder.loadingView() - } else if viewModel.error != nil { - WidgetContentBuilder.errorView(t("widgets__price__error")) - } else { - ForEach(filteredPriceData, id: \.name) { priceData in - PriceRow(data: priceData) - .accessibilityIdentifier("PriceWidgetRow-\(priceData.name)") - } - } - - if let firstPair = filteredPriceData.first { - PriceChart( - values: firstPair.pastValues, - isPositive: firstPair.change.isPositive, - period: options.selectedPeriod.rawValue - ) - .frame(height: 96) - .padding(.top, 8) - } - - if options.showSource { - WidgetContentBuilder.sourceRow(source: "Bitfinex.com") - .accessibilityIdentifier("PriceWidgetSource") - } - } + content } - .onAppear { - fetchPriceData() - } - .onChange(of: options.selectedPairs) { - fetchPriceData() - } - .onChange(of: options.selectedPeriod) { - fetchPriceData() + .onAppear { fetchPriceData() } + .onChange(of: options.selectedPairs) { fetchPriceData() } + .onChange(of: options.selectedPeriod) { fetchPriceData() } + } + + @ViewBuilder + private var content: some View { + if viewModel.isLoading && primaryPrice == nil { + WidgetContentBuilder.loadingView() + } else if viewModel.error != nil { + WidgetContentBuilder.errorView(t("widgets__price__error")) + } else if let primary = primaryPrice { + PriceWidgetWideContent(data: primary, period: options.selectedPeriod) } } - private var filteredPriceData: [PriceData] { + /// Single pair (v61). Falls back to first available data if the selection isn't loaded yet. + private var primaryPrice: PriceData? { let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod) - let dataByPair = Dictionary(uniqueKeysWithValues: currentPeriodData.map { ($0.name, $0) }) - return options.selectedPairs.compactMap { pair in - dataByPair[pair] + if let preferred = options.selectedPairs.first, + let match = currentPeriodData.first(where: { $0.name == preferred }) + { + return match } + return currentPeriodData.first } - /// Fetch price data from view model private func fetchPriceData() { viewModel.fetchPriceData(pairs: options.selectedPairs, period: options.selectedPeriod) } } -// MARK: - Price Row Component +// MARK: - Wide layout (in-app + carousel page) + +struct PriceWidgetWideContent: View { + let data: PriceData + let period: GraphPeriod + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 16) { + CaptionMText("\(data.name) \(period.rawValue)", textColor: .textSecondary) + .textCase(.uppercase) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(data.change.formatted) + .font(Fonts.bold(size: 22)) + .foregroundColor(data.change.isPositive ? .greenAccent : .redAccent) + .lineLimit(1) + } + + Text(data.price) + .font(Fonts.bold(size: 34)) + .foregroundColor(.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(maxWidth: .infinity, alignment: .leading) + } + + PriceChart(values: data.pastValues, isPositive: data.change.isPositive) + .frame(height: 48) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Compact layout (small carousel preview only) -struct PriceRow: View { +struct PriceWidgetCompactContent: View { let data: PriceData + let period: GraphPeriod var body: some View { - HStack { - BodySSBText(data.name, textColor: .textSecondary) + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 0) { + CaptionMText(data.name, textColor: .textSecondary) + .textCase(.uppercase) + Spacer(minLength: 0) + CaptionMText(period.rawValue, textColor: .textSecondary) + .textCase(.uppercase) + } + + Text(data.price) + .font(Fonts.bold(size: 22)) + .foregroundColor(.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) - Spacer() + Text(data.change.formatted) + .font(Fonts.semiBold(size: 15)) + .foregroundColor(data.change.isPositive ? .greenAccent : .redAccent) + .lineLimit(1) + } - BodySSBText(data.change.formatted, textColor: data.change.isPositive ? .greenAccent : .redAccent) - .padding(.trailing, 8) - BodySSBText(data.price, textColor: .textPrimary) + PriceChart(values: data.pastValues, isPositive: data.change.isPositive) + .frame(height: 64) } - .frame(minHeight: 28) + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.gray6) + .cornerRadius(16) } } -// MARK: - Price Chart Component +// MARK: - Chart (line-only per Figma v61) struct PriceChart: View { let values: [Double] let isPositive: Bool - let period: String - // Chart styling constants private let lineWidth: CGFloat = 1.3 - private let chartPadding: CGFloat = 4 - private let cornerRadius: CGFloat = 8 - private let gradientOpacityTop: CGFloat = 0.64 - private let gradientOpacityBottom: CGFloat = 0.08 private var normalizedValues: [Double] { guard values.count > 1 else { return values } @@ -127,76 +150,31 @@ struct PriceChart: View { guard range > 0 else { return values.map { _ in 0.5 } } - // Map to 0.15...0.85 range for more generous margins - // This prevents chart content from reaching the very edges where clipping occurs return values.map { value in let normalized = (value - minValue) / range - return 0.15 + (normalized * 0.7) // Maps 0-1 to 0.15-0.85 + return 0.15 + (normalized * 0.7) } } - private var chartColors: (gradient: [Color], line: Color) { - if isPositive { - return ( - gradient: [.greenAccent.opacity(gradientOpacityTop), .greenAccent.opacity(gradientOpacityBottom)], - line: .greenAccent - ) - } else { - return ( - gradient: [.redAccent.opacity(gradientOpacityTop), .redAccent.opacity(gradientOpacityBottom)], - line: .redAccent - ) - } + private var lineColor: Color { + isPositive ? .greenAccent : .redAccent } var body: some View { - ZStack(alignment: .bottomLeading) { - Chart { - ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in - // Area fill with gradient - AreaMark( - x: .value("Index", index), - y: .value("Price", value) - ) - .foregroundStyle( - LinearGradient( - colors: chartColors.gradient, - startPoint: .top, - endPoint: .bottom - ) - ) - .interpolationMethod(.catmullRom) - - // Line on top - LineMark( - x: .value("Index", index), - y: .value("Price", value) - ) - .foregroundStyle(chartColors.line) - .lineStyle(StrokeStyle(lineWidth: lineWidth)) - .interpolationMethod(.catmullRom) - } - } - .chartXAxis(.hidden) - .chartYAxis(.hidden) - // Y scale domain provides buffer zone beyond data range (0.15...0.85) - // This ensures chart elements (lines, curves) don't get clipped at edges - .chartYScale(domain: 0.1 ... 0.9) // Domain slightly larger than data range for extra buffer - // Apply rounded corners only to bottom - chart content extends to edges for visible clipping - // The internal margins above prevent any actual data from being cut off - .clipShape( - .rect( - topLeadingRadius: 0, - bottomLeadingRadius: cornerRadius, - bottomTrailingRadius: cornerRadius, - topTrailingRadius: 0 + Chart { + ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in + LineMark( + x: .value("Index", index), + y: .value("Price", value) ) - ) - - // Period label - CaptionBText(period, textColor: isPositive ? .green50 : .red50) - .padding(7) + .foregroundStyle(lineColor) + .lineStyle(StrokeStyle(lineWidth: lineWidth)) + .interpolationMethod(.catmullRom) + } } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartYScale(domain: 0.1 ... 0.9) } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 1eae66d3d..56858982d 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -430,7 +430,12 @@ struct MainNavView: View { // Widgets case .widgetsIntro: WidgetsIntroView() case .widgetsList: WidgetsListView() - case let .widgetDetail(widgetType): WidgetDetailView(id: widgetType) + case let .widgetDetail(widgetType): + if widgetType == .price { + PriceWidgetPreviewView() + } else { + WidgetDetailView(id: widgetType) + } case let .widgetEdit(widgetType): WidgetEditView(id: widgetType) // Settings diff --git a/Bitkit/Models/PriceWidgetOptions.swift b/Bitkit/Models/PriceWidgetOptions.swift index 94310d05f..987d838f7 100644 --- a/Bitkit/Models/PriceWidgetOptions.swift +++ b/Bitkit/Models/PriceWidgetOptions.swift @@ -1,8 +1,10 @@ import Foundation /// Options for configuring the in-app and home-screen price widgets (shared via App Group for the extension). +/// +/// `selectedPairs` is kept as an array for storage backwards-compatibility with v60. The v61 UI is +/// single-select and only ever reads/writes `[firstPair]`. struct PriceWidgetOptions: Codable, Equatable { var selectedPairs: [String] = ["BTC/USD"] var selectedPeriod: GraphPeriod = .oneDay - var showSource: Bool = false } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 766d3dfda..1dd4673ca 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1387,6 +1387,15 @@ "widgets__price__name" = "Bitcoin Price"; "widgets__price__description" = "Check the latest Bitcoin exchange rates for a variety of fiat currencies."; "widgets__price__error" = "Couldn\'t get price data"; +"widgets__price__currency" = "Currency"; +"widgets__price__timeframe" = "Timeframe"; +"widgets__price__period_day" = "Day"; +"widgets__price__period_week" = "Week"; +"widgets__price__period_month" = "Month"; +"widgets__price__period_year" = "Year"; +"widgets__price__size_small" = "Small"; +"widgets__price__size_wide" = "Wide"; +"widgets__price__widget_settings" = "Widget Settings"; "widgets__news__name" = "Bitcoin Headlines"; "widgets__news__description" = "Read the latest & greatest Bitcoin headlines from various news sites."; "widgets__news__error" = "Couldn\'t get the latest news"; diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 889a76095..f1ba83a71 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -66,7 +66,6 @@ enum WidgetsBackupConverter { pricePreferences = [ "enabledPairs": androidPairs.isEmpty ? ["BTC_USD"] : androidPairs, "period": androidPeriod, - "showSource": options.showSource, ] } case .calculator, .suggestions: @@ -179,8 +178,7 @@ enum WidgetsBackupConverter { let period = convertAndroidPeriodToIos(prefs["period"] as? String) let iosOptions = PriceWidgetOptions( selectedPairs: selectedPairs, - selectedPeriod: period, - showSource: prefs["showSource"] as? Bool ?? false + selectedPeriod: period ) optionsData = try? JSONEncoder().encode(iosOptions) } @@ -243,7 +241,6 @@ enum WidgetsBackupConverter { return [ "enabledPairs": androidPairs.isEmpty ? ["BTC_USD"] : androidPairs, "period": androidPeriod, - "showSource": defaults.showSource, ] } diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift new file mode 100644 index 000000000..4d4eeaf81 --- /dev/null +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -0,0 +1,263 @@ +import SwiftUI + +/// Preview screen for the Bitcoin Price widget (Figma v61). +/// +/// Replaces the generic `WidgetDetailView` for `.price` only — the other widgets continue to use +/// `WidgetDetailView`. Layout differences from the generic preview: centered top-bar title, +/// description, "Widget Settings" cell, and a Compact ↔ Wide carousel. +struct PriceWidgetPreviewView: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + + @StateObject private var viewModel = PriceViewModel.shared + + @State private var carouselPage: Int = 0 + @State private var showDeleteAlert = false + + private let widgetType: WidgetType = .price + + private var widgetName: String { + t("widgets__price__name") + } + + private var widgetDescription: String { + t("widgets__price__description") + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(widgetType) + } + + private var hasCustomOptions: Bool { + widgets.hasCustomOptions(for: widgetType) + } + + private var currentOptions: PriceWidgetOptions { + widgets.getOptions(for: widgetType, as: PriceWidgetOptions.self) + } + + private var primaryPrice: PriceData? { + let options = currentOptions + let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod) + if let preferred = options.selectedPairs.first, + let match = currentPeriodData.first(where: { $0.name == preferred }) + { + return match + } + return currentPeriodData.first + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: widgetName) + .padding(.bottom, 16) + + BodyMText(widgetDescription, textColor: .textSecondary) + .padding(.bottom, 16) + + Divider().background(Color.white.opacity(0.1)) + + widgetSettingsRow + + Divider().background(Color.white.opacity(0.1)) + + Spacer(minLength: 0) + + carousel + + sizeLabel + .padding(.top, 8) + + pageIndicator + .padding(.top, 8) + + Spacer(minLength: 0) + + buttonsRow + .padding(.top, 16) + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .onAppear { + let options = currentOptions + viewModel.fetchPriceData(pairs: options.selectedPairs, period: options.selectedPeriod) + } + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteAlert, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } + Button(t("common__delete_yes"), role: .destructive) { onDelete() } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": widgetName])) + } + ) + } + + // MARK: - Widget Settings cell + + private var widgetSettingsRow: some View { + Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { + HStack(alignment: .center, spacing: 0) { + BodyMText(t("widgets__price__widget_settings"), textColor: .textPrimary) + + Spacer() + + BodyMText( + hasCustomOptions + ? t("widgets__widget__edit_custom") + : t("widgets__widget__edit_default"), + textColor: .textSecondary + ) + + Image("chevron") + .resizable() + .foregroundColor(.textSecondary) + .frame(width: 24, height: 24) + .padding(.leading, 5) + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .padding(.vertical, 14) + .accessibilityIdentifier("WidgetEdit") + } + + // MARK: - Carousel + + private var carousel: some View { + TabView(selection: $carouselPage) { + compactPage + .tag(0) + + widePage + .tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(height: 240) + } + + private var compactPage: some View { + HStack { + Spacer() + Group { + if let data = primaryPrice { + PriceWidgetCompactContent(data: data, period: currentOptions.selectedPeriod) + } else { + placeholderCompact + } + } + .frame(width: 163, height: 192) + Spacer() + } + } + + private var widePage: some View { + HStack { + Spacer() + Group { + if let data = primaryPrice { + PriceWidgetWideContent(data: data, period: currentOptions.selectedPeriod) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + placeholderWide + } + } + .frame(maxWidth: .infinity) + Spacer() + } + } + + private var placeholderCompact: some View { + Color.gray6 + .cornerRadius(16) + .overlay(ProgressView()) + } + + private var placeholderWide: some View { + Color.gray6 + .cornerRadius(16) + .frame(height: 130) + .overlay(ProgressView()) + } + + // MARK: - Size label & page indicator + + private var sizeLabel: some View { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__price__size_small") + : t("widgets__price__size_wide"), + textColor: .textSecondary + ) + .textCase(.uppercase) + Spacer() + } + } + + private var pageIndicator: some View { + HStack(spacing: 8) { + Spacer() + ForEach(0 ..< 2, id: \.self) { index in + Circle() + .fill(carouselPage == index ? Color.brandAccent : Color.white.opacity(0.32)) + .frame(width: 8, height: 8) + } + Spacer() + } + } + + // MARK: - Buttons + + private var buttonsRow: some View { + HStack(spacing: 16) { + if isWidgetSaved { + CustomButton( + title: t("common__delete"), + variant: .secondary, + size: .large, + shouldExpand: true + ) { + showDeleteAlert = true + } + .accessibilityIdentifier("WidgetDelete") + } + + CustomButton( + title: t("common__save"), + variant: .primary, + size: .large, + shouldExpand: true, + action: onSave + ) + .accessibilityIdentifier("WidgetSave") + } + } + + // MARK: - Actions + + private func onSave() { + widgets.saveWidget(widgetType) + navigation.reset() + } + + private func onDelete() { + widgets.deleteWidget(widgetType) + navigation.reset() + } +} + +#Preview { + NavigationStack { + PriceWidgetPreviewView() + .environmentObject(NavigationViewModel()) + .environmentObject(WidgetsViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Widgets/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index 423477dbe..572044c4b 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -5,7 +5,24 @@ struct WidgetEditItemView: View { let onToggle: () -> Void var body: some View { - let content = VStack(spacing: 0) { + switch item.type { + case .sectionHeader: + item.titleView + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) + .padding(.bottom, 16) + case .staticItem: + row + case .toggleItem: + Button(action: onToggle) { + row + } + .buttonStyle(PlainButtonStyle()) + } + } + + private var row: some View { + VStack(spacing: 0) { HStack(spacing: 16) { item.titleView .frame(maxWidth: .infinity, alignment: .leading) @@ -17,24 +34,17 @@ struct WidgetEditItemView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - Image("check-mark") - .resizable() - .foregroundColor(item.isChecked ? .brandAccent : .gray3) - .frame(width: 32, height: 32) + if item.type != .staticItem { + Image("check-mark") + .resizable() + .foregroundColor(item.isChecked ? .brandAccent : .gray3) + .frame(width: 32, height: 32) + } } .padding(.vertical, 16) .contentShape(Rectangle()) Divider() } - - if item.type == .staticItem { - content - } else { - Button(action: onToggle) { - content - } - .buttonStyle(PlainButtonStyle()) - } } } diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index 3820ab6f0..a991264c1 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -138,14 +138,8 @@ class WidgetEditLogic: ObservableObject { } case .price: switch item.key { - case "BTC/USD": - toggleTradingPair("BTC/USD") - case "BTC/EUR": - toggleTradingPair("BTC/EUR") - case "BTC/GBP": - toggleTradingPair("BTC/GBP") - case "BTC/JPY": - toggleTradingPair("BTC/JPY") + case "BTC/USD", "BTC/EUR", "BTC/GBP", "BTC/JPY": + selectTradingPair(item.key) case "1D": priceOptions.selectedPeriod = .oneDay case "1W": @@ -154,8 +148,6 @@ class WidgetEditLogic: ObservableObject { priceOptions.selectedPeriod = .oneMonth case "1Y": priceOptions.selectedPeriod = .oneYear - case "showSource": - priceOptions.showSource.toggle() default: break } @@ -165,12 +157,9 @@ class WidgetEditLogic: ObservableObject { onStateChange?() } - private func toggleTradingPair(_ pairName: String) { - if priceOptions.selectedPairs.contains(pairName) { - priceOptions.selectedPairs.removeAll { $0 == pairName } - } else { - priceOptions.selectedPairs.append(pairName) - } + /// Single-select per Figma v61 — replaces the array with a single pair. + private func selectTradingPair(_ pairName: String) { + priceOptions.selectedPairs = [pairName] } func loadCurrentOptions() { diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 575f500b6..f4f75989e 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -1,10 +1,27 @@ import SwiftUI +// MARK: - GraphPeriod display + +extension GraphPeriod { + /// Full-word label shown in the Price edit screen (Day / Week / Month / Year). + /// The widget itself uses `rawValue` ("1D"/...) per Figma v61. + var editScreenLabel: String { + switch self { + case .oneDay: return t("widgets__price__period_day") + case .oneWeek: return t("widgets__price__period_week") + case .oneMonth: return t("widgets__price__period_month") + case .oneYear: return t("widgets__price__period_year") + } + } +} + // MARK: - Widget Edit Item Models enum WidgetItemType { case toggleItem case staticItem + /// Non-tappable section header (uppercase caption above a group of items). + case sectionHeader } struct WidgetEditItem { @@ -357,68 +374,62 @@ enum WidgetEditItemFactory { } @MainActor - static func getPriceItems(priceOptions: PriceWidgetOptions, priceDataByPeriod: [GraphPeriod: [PriceData]] = [:]) -> [WidgetEditItem] { + static func getPriceItems(priceOptions: PriceWidgetOptions, priceDataByPeriod _: [GraphPeriod: [PriceData]] = [:]) -> [WidgetEditItem] { var items: [WidgetEditItem] = [] - // Trading pair options with live or fallback prices - let fallbackPrices = ["$ 43,250", "€ 39,850", "£ 34,120", "¥ 6,245,000"] - - // Use current period data for trading pair prices - let currentPeriodData = priceDataByPeriod[priceOptions.selectedPeriod] ?? [] - - for (index, pair) in tradingPairNames.enumerated() { - // Try to find live data for this pair - let livePrice = currentPeriodData.first { $0.name == pair }?.price ?? fallbackPrices[index] + // CURRENCY section (single-select) + items.append(sectionHeaderItem(key: "currency_header", title: t("widgets__price__currency"))) + let selectedPair = priceOptions.selectedPairs.first + for pair in tradingPairNames { + let isSelected = selectedPair == pair items.append( WidgetEditItem( key: pair, type: .toggleItem, - title: pair, - value: livePrice, - isChecked: priceOptions.selectedPairs.contains(pair) + titleView: AnyView( + BodySSBText(pair, textColor: isSelected ? .textPrimary : .textSecondary) + ), + valueView: nil, + isChecked: isSelected ) ) } - // Period selection (radio group) with charts - let periods: [GraphPeriod] = [.oneDay, .oneWeek, .oneMonth, .oneYear] - - for period in periods { - // Get data for this specific period - let periodData = priceDataByPeriod[period] ?? [] - let firstPairData = periodData.first + // TIMEFRAME section (single-select). Full-word labels per Figma v61. + items.append(sectionHeaderItem(key: "timeframe_header", title: t("widgets__price__timeframe"))) + for period in GraphPeriod.allCases { + let isSelected = priceOptions.selectedPeriod == period items.append( WidgetEditItem( key: period.rawValue, type: .toggleItem, titleView: AnyView( - PriceChart( - values: firstPairData?.pastValues ?? [], - isPositive: firstPairData?.change.isPositive ?? true, - period: period.rawValue - ) + BodySSBText(period.editScreenLabel, textColor: isSelected ? .textPrimary : .textSecondary) ), valueView: nil, - isChecked: priceOptions.selectedPeriod == period + isChecked: isSelected ) ) } - items.append( - WidgetEditItem( - key: "showSource", - type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("Bitfinex.com", textColor: .textSecondary)), - isChecked: priceOptions.showSource - ) - ) - return items } + private static func sectionHeaderItem(key: String, title: String) -> WidgetEditItem { + WidgetEditItem( + key: key, + type: .sectionHeader, + titleView: AnyView( + CaptionMText(title, textColor: .textSecondary) + .textCase(.uppercase) + ), + valueView: nil, + isChecked: false + ) + } + @MainActor static func getWeatherItems( weatherViewModel: WeatherViewModel, diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index 8cfc81174..d96a89d17 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -57,14 +57,16 @@ struct WidgetEditView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("widgets__widget__edit")) + NavigationBar(title: id == .price ? widget.name : t("widgets__widget__edit")) .padding(.bottom, 16) - BodyMText( - t("widgets__widget__edit_description", variables: ["name": widget.name]), - textColor: .textSecondary - ) - .padding(.bottom, 16) + if id != .price { + BodyMText( + t("widgets__widget__edit_description", variables: ["name": widget.name]), + textColor: .textSecondary + ) + .padding(.bottom, 16) + } ScrollView(showsIndicators: false) { LazyVStack(spacing: 0) { diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index e1a44af7e..ae3c22e1b 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -86,123 +86,126 @@ struct PriceHomeScreenWidgetEntryView: View { 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 } + content + .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 { + } else if let primary = primaryPrice { switch widgetFamily { case .systemSmall: - smallContent + compactLayout(data: primary) default: - rowsAndChart + wideLayout(data: primary) } + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + + /// Always render the first selected pair (v61 is single-pair). + private var primaryPrice: PriceData? { + let preferred = entry.options.selectedPairs.first + if let preferred, let match = entry.prices.first(where: { $0.name == preferred }) { + return match } + return entry.prices.first } - // MARK: - Variants + // MARK: - Compact (small widget — 163×192) - 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) + private func compactLayout(data: PriceData) -> some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 0) { + captionUpText(data.name) + Spacer(minLength: 0) + captionUpText(entry.options.selectedPeriod.rawValue) + } - Text(primary?.price ?? "—") - .font(Fonts.bold(size: 22)) - .foregroundColor(valueTextColor) - .lineLimit(1) - .minimumScaleFactor(0.7) - .widgetAccentable() + priceText(data.price, size: 22, lineHeight: 26) - if let change = primary?.change { - BodySSBText(change.formatted, textColor: changeColor(isPositive: change.isPositive)) + Text(data.change.formatted) + .font(Fonts.semiBold(size: 15)) + .foregroundColor(changeColor(isPositive: data.change.isPositive)) .lineLimit(1) .widgetAccentable() } - Spacer(minLength: 0) + chart(values: data.pastValues, isPositive: data.change.isPositive, height: 64) } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private var rowsAndChart: some View { - VStack(spacing: 0) { - ForEach(visibleRows, id: \.name) { data in - priceRow(data: data) - } + // MARK: - Wide (medium / large widget) - 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 func wideLayout(data: PriceData) -> some View { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 16) { + captionUpText("\(data.name) \(entry.options.selectedPeriod.rawValue)") + .frame(maxWidth: .infinity, alignment: .leading) + + Text(data.change.formatted) + .font(Fonts.bold(size: 22)) + .foregroundColor(changeColor(isPositive: data.change.isPositive)) + .lineLimit(1) + .widgetAccentable() + } + + priceText(data.price, size: 34, lineHeight: 34) } - } - } - 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)) + chart(values: data.pastValues, isPositive: data.change.isPositive, height: chartHeight) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private var chartHeight: CGFloat { switch widgetFamily { - case .systemMedium: 64 - case .systemLarge, .systemExtraLarge: 120 - default: 96 + case .systemLarge, .systemExtraLarge: return 120 + default: return 48 } } - private var errorView: some View { - Text("Couldn’t load price.") - .font(Fonts.medium(size: 14)) + // MARK: - Sub-views + + private func captionUpText(_ text: String) -> Text { + Text(text) + .font(Fonts.medium(size: 13)) + .tracking(1) .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) + private func priceText(_ value: String, size: CGFloat, lineHeight: CGFloat) -> some View { + Text(value) + .font(Fonts.bold(size: size)) + .foregroundColor(valueTextColor) + .lineLimit(1) + .minimumScaleFactor(0.7) + .widgetAccentable() + } - BodySSBText(data.change.formatted, textColor: changeColor(isPositive: data.change.isPositive)) - .padding(.trailing, 8) - .lineLimit(1) - .widgetAccentable() + private func chart(values: [Double], isPositive: Bool, height: CGFloat) -> some View { + PriceWidgetChart( + values: values, + isPositive: isPositive, + renderingMode: widgetRenderingMode + ) + .frame(height: height) + .widgetAccentable() + } - BodySSBText(data.price, textColor: valueTextColor) - .lineLimit(1) - .minimumScaleFactor(0.75) - .widgetAccentable() - } - .frame(minHeight: 24) + private var errorView: some View { + // Hardcoded — widget extension target does not bundle the app's localization helpers. + Text("Couldn’t load price.") + .font(Fonts.medium(size: 14)) + .foregroundColor(secondaryTextColor) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } // MARK: - Colors @@ -225,12 +228,11 @@ struct PriceHomeScreenWidgetEntryView: View { } } -// MARK: - Chart +// MARK: - Chart (line-only per Figma v61) private struct PriceWidgetChart: View { let values: [Double] let isPositive: Bool - let period: String let renderingMode: WidgetRenderingMode private var normalizedValues: [Double] { @@ -247,55 +249,21 @@ private struct PriceWidgetChart: View { 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 + Chart { + ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in + LineMark( + x: .value("Index", index), + y: .value("Price", value) ) - ) - .widgetAccentable() - - CaptionBText(period, textColor: labelColor) - .padding(7) + .foregroundStyle(lineColor) + .lineStyle(StrokeStyle(lineWidth: 1.3)) + .interpolationMethod(.catmullRom) + } } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartYScale(domain: 0.1 ... 0.9) } } diff --git a/changelog.d/next/price-widget-v61.changed.md b/changelog.d/next/price-widget-v61.changed.md new file mode 100644 index 000000000..d6bc025ee --- /dev/null +++ b/changelog.d/next/price-widget-v61.changed.md @@ -0,0 +1 @@ +Redesign the Bitcoin Price widget (in-app and home screen) to match Figma v61: single-currency selection, dedicated wide/compact layouts, line-only chart, and an updated edit and preview flow. From 28a550f2e4e30060eaed4a9f22b51a82cf46ec88 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 08:07:53 -0300 Subject: [PATCH 10/60] fix: spacing and alignment --- .../Localization/en.lproj/Localizable.strings | 1 + .../Widgets/PriceWidgetPreviewView.swift | 55 ++++++++++--------- Bitkit/Views/Widgets/WidgetEditItemView.swift | 3 +- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 1dd4673ca..968bc586c 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1396,6 +1396,7 @@ "widgets__price__size_small" = "Small"; "widgets__price__size_wide" = "Wide"; "widgets__price__widget_settings" = "Widget Settings"; +"widgets__widget__save_widget" = "Save Widget"; "widgets__news__name" = "Bitcoin Headlines"; "widgets__news__description" = "Read the latest & greatest Bitcoin headlines from various news sites."; "widgets__news__error" = "Couldn\'t get the latest news"; diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index 4d4eeaf81..48652a637 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -48,33 +48,36 @@ struct PriceWidgetPreviewView: View { } var body: some View { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 16) { NavigationBar(title: widgetName) - .padding(.bottom, 16) - BodyMText(widgetDescription, textColor: .textSecondary) - .padding(.bottom, 16) + // Content (description + Widget Settings cell with surrounding dividers) + VStack(alignment: .leading, spacing: 0) { + BodyMText(widgetDescription, textColor: .textSecondary) + .padding(.bottom, 16) - Divider().background(Color.white.opacity(0.1)) + Divider().background(Color.white.opacity(0.1)) - widgetSettingsRow + widgetSettingsRow - Divider().background(Color.white.opacity(0.1)) + Divider().background(Color.white.opacity(0.1)) + } - Spacer(minLength: 0) + // Carousel section (centered widget + size label + page indicator) + VStack(spacing: 16) { + Spacer(minLength: 0) - carousel + carousel - sizeLabel - .padding(.top, 8) + Spacer(minLength: 0) - pageIndicator - .padding(.top, 8) + sizeLabel - Spacer(minLength: 0) + pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) buttonsRow - .padding(.top, 16) } .navigationBarHidden(true) .padding(.horizontal, 16) @@ -117,11 +120,10 @@ struct PriceWidgetPreviewView: View { .frame(width: 24, height: 24) .padding(.leading, 5) } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, minHeight: 51) .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) - .padding(.vertical, 14) .accessibilityIdentifier("WidgetEdit") } @@ -136,12 +138,12 @@ struct PriceWidgetPreviewView: View { .tag(1) } .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(height: 240) + .frame(height: 320) } private var compactPage: some View { - HStack { - Spacer() + VStack { + Spacer(minLength: 0) Group { if let data = primaryPrice { PriceWidgetCompactContent(data: data, period: currentOptions.selectedPeriod) @@ -150,13 +152,14 @@ struct PriceWidgetPreviewView: View { } } .frame(width: 163, height: 192) - Spacer() + Spacer(minLength: 0) } + .frame(maxWidth: .infinity) } private var widePage: some View { - HStack { - Spacer() + VStack { + Spacer(minLength: 0) Group { if let data = primaryPrice { PriceWidgetWideContent(data: data, period: currentOptions.selectedPeriod) @@ -168,7 +171,7 @@ struct PriceWidgetPreviewView: View { } } .frame(maxWidth: .infinity) - Spacer() + Spacer(minLength: 0) } } @@ -181,7 +184,7 @@ struct PriceWidgetPreviewView: View { private var placeholderWide: some View { Color.gray6 .cornerRadius(16) - .frame(height: 130) + .frame(height: 152) .overlay(ProgressView()) } @@ -230,7 +233,7 @@ struct PriceWidgetPreviewView: View { } CustomButton( - title: t("common__save"), + title: t("widgets__widget__save_widget"), variant: .primary, size: .large, shouldExpand: true, diff --git a/Bitkit/Views/Widgets/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index 572044c4b..4b4b8bbb7 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -9,8 +9,7 @@ struct WidgetEditItemView: View { case .sectionHeader: item.titleView .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 8) - .padding(.bottom, 16) + .padding(.vertical, 16) case .staticItem: row case .toggleItem: From 10be29edab7379dd4ddabf4f677c437e4e138e7e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 08:09:17 -0300 Subject: [PATCH 11/60] feat: hide menu button from nabigation bar --- Bitkit/Views/Widgets/PriceWidgetPreviewView.swift | 2 +- Bitkit/Views/Widgets/WidgetEditView.swift | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index 48652a637..b8a341c60 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -49,7 +49,7 @@ struct PriceWidgetPreviewView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - NavigationBar(title: widgetName) + NavigationBar(title: widgetName, showMenuButton: false) // Content (description + Widget Settings cell with surrounding dividers) VStack(alignment: .leading, spacing: 0) { diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index d96a89d17..e119ee310 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -57,8 +57,11 @@ struct WidgetEditView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: id == .price ? widget.name : t("widgets__widget__edit")) - .padding(.bottom, 16) + NavigationBar( + title: id == .price ? widget.name : t("widgets__widget__edit"), + showMenuButton: id != .price + ) + .padding(.bottom, 16) if id != .price { BodyMText( From 90680fed51b735002546101d84358a311ca6b467 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 08:15:20 -0300 Subject: [PATCH 12/60] fix: padding --- Bitkit/Views/Widgets/WidgetEditModels.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index f4f75989e..fa1db48ca 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -396,8 +396,7 @@ enum WidgetEditItemFactory { ) } - // TIMEFRAME section (single-select). Full-word labels per Figma v61. - items.append(sectionHeaderItem(key: "timeframe_header", title: t("widgets__price__timeframe"))) + items.append(sectionHeaderItem(key: "timeframe_header", title: t("widgets__price__timeframe"), topInset: 16)) for period in GraphPeriod.allCases { let isSelected = priceOptions.selectedPeriod == period @@ -417,13 +416,14 @@ enum WidgetEditItemFactory { return items } - private static func sectionHeaderItem(key: String, title: String) -> WidgetEditItem { + private static func sectionHeaderItem(key: String, title: String, topInset: CGFloat = 0) -> WidgetEditItem { WidgetEditItem( key: key, type: .sectionHeader, titleView: AnyView( CaptionMText(title, textColor: .textSecondary) .textCase(.uppercase) + .padding(.top, topInset) ), valueView: nil, isChecked: false From 4dfd9a80d0cb134463b0f39afffe19313adc907a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 08:24:56 -0300 Subject: [PATCH 13/60] fix: remove systemLarge widget option --- BitkitWidget/PriceHomeScreenWidget.swift | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index ae3c22e1b..ed6fd31d4 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -160,18 +160,11 @@ struct PriceHomeScreenWidgetEntryView: View { priceText(data.price, size: 34, lineHeight: 34) } - chart(values: data.pastValues, isPositive: data.change.isPositive, height: chartHeight) + chart(values: data.pastValues, isPositive: data.change.isPositive, height: 48) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private var chartHeight: CGFloat { - switch widgetFamily { - case .systemLarge, .systemExtraLarge: return 120 - default: return 48 - } - } - // MARK: - Sub-views private func captionUpText(_ text: String) -> Text { @@ -279,6 +272,6 @@ struct BitkitPriceWidget: Widget { } .configurationDisplayName("Bitcoin Price") .description("Latest Bitcoin price and chart, mirroring the in-app price widget.") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .supportedFamilies([.systemSmall, .systemMedium]) } } From c3a75fff205c680709e7e5382891e8d0dc0207b4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 08:41:38 -0300 Subject: [PATCH 14/60] 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 15dc374167acb4e390272a0d2d9af67de73b4354 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 08:47:25 -0300 Subject: [PATCH 15/60] fix: collect results in input order instead of completion order --- BitkitWidget/PriceWidgetService.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/BitkitWidget/PriceWidgetService.swift b/BitkitWidget/PriceWidgetService.swift index 0574bdf22..c07ba499f 100644 --- a/BitkitWidget/PriceWidgetService.swift +++ b/BitkitWidget/PriceWidgetService.swift @@ -22,16 +22,19 @@ enum PriceWidgetService { // 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) } + let results = await withTaskGroup(of: (Int, PriceData?).self) { group -> [PriceData] in + for (index, pair) in pairs.enumerated() { + group.addTask { + let data = try? await fetchPair(pairName: pair, period: period) + return (index, data) + } } - var collected: [PriceData] = [] - for await result in group { - if let result { collected.append(result) } + var collected: [(Int, PriceData)] = [] + for await (index, result) in group { + if let result { collected.append((index, result)) } } - return collected + return collected.sorted { $0.0 < $1.0 }.map(\.1) } guard !results.isEmpty else { throw FetchError.noPriceDataAvailable } From 53123609069f4411f20f70122d432e639a1988a7 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 08:56:20 -0300 Subject: [PATCH 16/60] 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. - From 600b422f1ffa05768bf76ce7b86bec28c7011d0a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:09:46 -0300 Subject: [PATCH 17/60] fix: pr comments --- changelog.d/next/{price-widget-v61.changed.md => 542.changed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{price-widget-v61.changed.md => 542.changed.md} (100%) diff --git a/changelog.d/next/price-widget-v61.changed.md b/changelog.d/next/542.changed.md similarity index 100% rename from changelog.d/next/price-widget-v61.changed.md rename to changelog.d/next/542.changed.md From 027da4129d5622213361d5ebba84a2b433eafd0a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:09:51 -0300 Subject: [PATCH 18/60] fix: pr comments --- Bitkit/Components/Widgets/PriceWidget.swift | 18 ++++++++++-------- .../Views/Widgets/PriceWidgetPreviewView.swift | 2 +- Bitkit/Views/Widgets/WidgetEditItemView.swift | 4 ++-- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index a5935567e..c192f4e9c 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -73,10 +73,11 @@ struct PriceWidgetWideContent: View { .textCase(.uppercase) .frame(maxWidth: .infinity, alignment: .leading) - Text(data.change.formatted) - .font(Fonts.bold(size: 22)) - .foregroundColor(data.change.isPositive ? .greenAccent : .redAccent) - .lineLimit(1) + TitleText( + data.change.formatted, + textColor: data.change.isPositive ? .greenAccent : .redAccent + ) + .lineLimit(1) } Text(data.price) @@ -117,10 +118,11 @@ struct PriceWidgetCompactContent: View { .lineLimit(1) .minimumScaleFactor(0.7) - Text(data.change.formatted) - .font(Fonts.semiBold(size: 15)) - .foregroundColor(data.change.isPositive ? .greenAccent : .redAccent) - .lineLimit(1) + BodySSBText( + data.change.formatted, + textColor: data.change.isPositive ? .greenAccent : .redAccent + ) + .lineLimit(1) } PriceChart(values: data.pastValues, isPositive: data.change.isPositive) diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index b8a341c60..aa082f2e1 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -81,7 +81,7 @@ struct PriceWidgetPreviewView: View { } .navigationBarHidden(true) .padding(.horizontal, 16) - .onAppear { + .task { let options = currentOptions viewModel.fetchPriceData(pairs: options.selectedPairs, period: options.selectedPeriod) } diff --git a/Bitkit/Views/Widgets/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index 4b4b8bbb7..c21c8ad01 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -33,10 +33,10 @@ struct WidgetEditItemView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - if item.type != .staticItem { + if item.type != .staticItem, item.isChecked { Image("check-mark") .resizable() - .foregroundColor(item.isChecked ? .brandAccent : .gray3) + .foregroundColor(.brandAccent) .frame(width: 32, height: 32) } } From a17212970fa7771b1a63909a2290231a58ee032b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:13:38 -0300 Subject: [PATCH 19/60] refactor: simplify doc --- Bitkit/Components/Widgets/PriceWidget.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index c192f4e9c..33e9dd547 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -1,7 +1,7 @@ import Charts import SwiftUI -/// Displays Bitcoin price for the user's selected trading pair and timeframe (Figma v61). +/// Displays Bitcoin price for the user's selected trading pair and timeframe. struct PriceWidget: View { var options: PriceWidgetOptions = .init() var isEditing: Bool = false @@ -43,7 +43,7 @@ struct PriceWidget: View { } } - /// Single pair (v61). Falls back to first available data if the selection isn't loaded yet. + /// Single pair. Falls back to first available data if the selection isn't loaded yet. private var primaryPrice: PriceData? { let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod) if let preferred = options.selectedPairs.first, @@ -135,7 +135,7 @@ struct PriceWidgetCompactContent: View { } } -// MARK: - Chart (line-only per Figma v61) +// MARK: - Chart struct PriceChart: View { let values: [Double] From 73eba60646098867403815ba8552a30d57c61b47 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:15:57 -0300 Subject: [PATCH 20/60] refactor: replace onApper with task --- Bitkit/Components/Widgets/PriceWidget.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index 33e9dd547..b0d832ef8 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -27,7 +27,7 @@ struct PriceWidget: View { ) { content } - .onAppear { fetchPriceData() } + .task { fetchPriceData() } .onChange(of: options.selectedPairs) { fetchPriceData() } .onChange(of: options.selectedPeriod) { fetchPriceData() } } From d07317f89706fe6bb2d8dd7868917eed113308c8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:19:20 -0300 Subject: [PATCH 21/60] refactor: replace onChange with task id --- Bitkit/Components/Widgets/PriceWidget.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index b0d832ef8..f4deacc9b 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -27,9 +27,7 @@ struct PriceWidget: View { ) { content } - .task { fetchPriceData() } - .onChange(of: options.selectedPairs) { fetchPriceData() } - .onChange(of: options.selectedPeriod) { fetchPriceData() } + .task(id: options) { fetchPriceData() } } @ViewBuilder From 8b2ec9728d07ad7a37776cb4515bdc1ed88eb4b6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:41:38 -0300 Subject: [PATCH 22/60] refactor: simplify comments --- Bitkit/Views/Widgets/PriceWidgetPreviewView.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index aa082f2e1..5dbd7c1e9 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -1,10 +1,6 @@ import SwiftUI -/// Preview screen for the Bitcoin Price widget (Figma v61). -/// -/// Replaces the generic `WidgetDetailView` for `.price` only — the other widgets continue to use -/// `WidgetDetailView`. Layout differences from the generic preview: centered top-bar title, -/// description, "Widget Settings" cell, and a Compact ↔ Wide carousel. +/// Preview screen for the Bitcoin Price widget. struct PriceWidgetPreviewView: View { @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var widgets: WidgetsViewModel From 658614369cf5138ddef4395bb9c20da6b11b7df6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:43:32 -0300 Subject: [PATCH 23/60] refactor: simplyfy comments --- Bitkit/Views/Widgets/PriceWidgetPreviewView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index 5dbd7c1e9..9a34abe36 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -47,7 +47,6 @@ struct PriceWidgetPreviewView: View { VStack(alignment: .leading, spacing: 16) { NavigationBar(title: widgetName, showMenuButton: false) - // Content (description + Widget Settings cell with surrounding dividers) VStack(alignment: .leading, spacing: 0) { BodyMText(widgetDescription, textColor: .textSecondary) .padding(.bottom, 16) @@ -59,7 +58,6 @@ struct PriceWidgetPreviewView: View { Divider().background(Color.white.opacity(0.1)) } - // Carousel section (centered widget + size label + page indicator) VStack(spacing: 16) { Spacer(minLength: 0) From fc0dc8ecb7324ab4348bea5307b23dad1ce67c47 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:46:20 -0300 Subject: [PATCH 24/60] refactor: simplify comments --- Bitkit/Models/PriceWidgetOptions.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Bitkit/Models/PriceWidgetOptions.swift b/Bitkit/Models/PriceWidgetOptions.swift index 987d838f7..d0bc0e51f 100644 --- a/Bitkit/Models/PriceWidgetOptions.swift +++ b/Bitkit/Models/PriceWidgetOptions.swift @@ -1,9 +1,6 @@ import Foundation /// Options for configuring the in-app and home-screen price widgets (shared via App Group for the extension). -/// -/// `selectedPairs` is kept as an array for storage backwards-compatibility with v60. The v61 UI is -/// single-select and only ever reads/writes `[firstPair]`. struct PriceWidgetOptions: Codable, Equatable { var selectedPairs: [String] = ["BTC/USD"] var selectedPeriod: GraphPeriod = .oneDay From d01c196e1da257c4762646ab71476e521ab9ea12 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 10:00:27 -0300 Subject: [PATCH 25/60] refactor: simplify comments --- Bitkit/Views/Widgets/WidgetEditLogic.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index a991264c1..562864e18 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -157,7 +157,6 @@ class WidgetEditLogic: ObservableObject { onStateChange?() } - /// Single-select per Figma v61 — replaces the array with a single pair. private func selectTradingPair(_ pairName: String) { priceOptions.selectedPairs = [pairName] } From 770511ec7ea2ba203b9789f33bf5a9d5e495675d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 10:17:53 -0300 Subject: [PATCH 26/60] refactor: simplify comments --- BitkitWidget/PriceHomeScreenWidget.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index 68200c5dc..c3de7cd92 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -107,7 +107,6 @@ struct PriceHomeScreenWidgetEntryView: View { } } - /// Always render the first selected pair (v61 is single-pair). private var primaryPrice: PriceData? { let preferred = entry.options.selectedPairs.first if let preferred, let match = entry.prices.first(where: { $0.name == preferred }) { @@ -218,7 +217,7 @@ struct PriceHomeScreenWidgetEntryView: View { } } -// MARK: - Chart (line-only per Figma v61) +// MARK: - Chart private struct PriceWidgetChart: View { let values: [Double] From 5f0841fab2050e3b6f01d9bbb7f802b7a31b80d6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 10:28:45 -0300 Subject: [PATCH 27/60] refactor: remove multi-pair legacy code --- Bitkit/Components/Widgets/PriceWidget.swift | 6 +-- Bitkit/Models/PriceWidgetOptions.swift | 38 ++++++++++++++++++- Bitkit/Utilities/WidgetsBackupConverter.swift | 28 ++++++-------- .../Widgets/PriceWidgetPreviewView.swift | 6 +-- Bitkit/Views/Widgets/WidgetEditLogic.swift | 6 +-- Bitkit/Views/Widgets/WidgetEditModels.swift | 2 +- BitkitWidget/PriceHomeScreenWidget.swift | 9 ++--- 7 files changed, 60 insertions(+), 35 deletions(-) diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index f4deacc9b..0d1323770 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -44,16 +44,14 @@ struct PriceWidget: View { /// Single pair. Falls back to first available data if the selection isn't loaded yet. private var primaryPrice: PriceData? { let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod) - if let preferred = options.selectedPairs.first, - let match = currentPeriodData.first(where: { $0.name == preferred }) - { + if let match = currentPeriodData.first(where: { $0.name == options.selectedPair }) { return match } return currentPeriodData.first } private func fetchPriceData() { - viewModel.fetchPriceData(pairs: options.selectedPairs, period: options.selectedPeriod) + viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) } } diff --git a/Bitkit/Models/PriceWidgetOptions.swift b/Bitkit/Models/PriceWidgetOptions.swift index d0bc0e51f..f4f90dcb0 100644 --- a/Bitkit/Models/PriceWidgetOptions.swift +++ b/Bitkit/Models/PriceWidgetOptions.swift @@ -1,7 +1,41 @@ import Foundation -/// Options for configuring the in-app and home-screen price widgets (shared via App Group for the extension). +/// Options for configuring the in-app and home-screen price widgets (shared via App Group). +/// struct PriceWidgetOptions: Codable, Equatable { - var selectedPairs: [String] = ["BTC/USD"] + var selectedPair: String = "BTC/USD" var selectedPeriod: GraphPeriod = .oneDay + + init(selectedPair: String = "BTC/USD", selectedPeriod: GraphPeriod = .oneDay) { + self.selectedPair = selectedPair + self.selectedPeriod = selectedPeriod + } + + private enum CodingKeys: String, CodingKey { + case selectedPair + case selectedPairs // legacy v60 key + case selectedPeriod + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let pair = try container.decodeIfPresent(String.self, forKey: .selectedPair) { + selectedPair = pair + } else if let legacyPairs = try container.decodeIfPresent([String].self, forKey: .selectedPairs), + let first = legacyPairs.first + { + selectedPair = first + } else { + selectedPair = "BTC/USD" + } + + selectedPeriod = try container.decodeIfPresent(GraphPeriod.self, forKey: .selectedPeriod) ?? .oneDay + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(selectedPair, forKey: .selectedPair) + try container.encode(selectedPeriod, forKey: .selectedPeriod) + } } diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index f1ba83a71..6d0e392c8 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -59,12 +59,10 @@ enum WidgetsBackupConverter { } case .price: if let options = try? JSONDecoder().decode(PriceWidgetOptions.self, from: optionsData) { - let androidPairs = options.selectedPairs.map { pair in - pair.replacingOccurrences(of: "/", with: "_") - } + let androidPair = options.selectedPair.replacingOccurrences(of: "/", with: "_") let androidPeriod = convertIosPeriodToAndroid(options.selectedPeriod) pricePreferences = [ - "enabledPairs": androidPairs.isEmpty ? ["BTC_USD"] : androidPairs, + "enabledPairs": [androidPair.isEmpty ? "BTC_USD" : androidPair], "period": androidPeriod, ] } @@ -165,19 +163,19 @@ enum WidgetsBackupConverter { } case .price: if let prefs = jsonDict["pricePreferences"] as? [String: Any] { - var selectedPairs = ["BTC/USD"] - if let pairsArray = prefs["enabledPairs"] as? [String] { - selectedPairs = pairsArray.map { pairType in - pairType.replacingOccurrences(of: "_", with: "/") - } - if selectedPairs.isEmpty { - selectedPairs = ["BTC/USD"] + var selectedPair = "BTC/USD" + if let pairsArray = prefs["enabledPairs"] as? [String], + let firstAndroidPair = pairsArray.first + { + let converted = firstAndroidPair.replacingOccurrences(of: "_", with: "/") + if !converted.isEmpty { + selectedPair = converted } } let period = convertAndroidPeriodToIos(prefs["period"] as? String) let iosOptions = PriceWidgetOptions( - selectedPairs: selectedPairs, + selectedPair: selectedPair, selectedPeriod: period ) optionsData = try? JSONEncoder().encode(iosOptions) @@ -234,12 +232,10 @@ enum WidgetsBackupConverter { private static func getDefaultPricePreferences() -> [String: Any] { let defaults = PriceWidgetOptions() - let androidPairs = defaults.selectedPairs.map { pair in - pair.replacingOccurrences(of: "/", with: "_") - } + let androidPair = defaults.selectedPair.replacingOccurrences(of: "/", with: "_") let androidPeriod = convertIosPeriodToAndroid(defaults.selectedPeriod) return [ - "enabledPairs": androidPairs.isEmpty ? ["BTC_USD"] : androidPairs, + "enabledPairs": [androidPair.isEmpty ? "BTC_USD" : androidPair], "period": androidPeriod, ] } diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index 9a34abe36..afead9ce9 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -35,9 +35,7 @@ struct PriceWidgetPreviewView: View { private var primaryPrice: PriceData? { let options = currentOptions let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod) - if let preferred = options.selectedPairs.first, - let match = currentPeriodData.first(where: { $0.name == preferred }) - { + if let match = currentPeriodData.first(where: { $0.name == options.selectedPair }) { return match } return currentPeriodData.first @@ -77,7 +75,7 @@ struct PriceWidgetPreviewView: View { .padding(.horizontal, 16) .task { let options = currentOptions - viewModel.fetchPriceData(pairs: options.selectedPairs, period: options.selectedPeriod) + viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) } .alert( t("widgets__delete__title"), diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index 562864e18..e6b806683 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -44,8 +44,8 @@ class WidgetEditLogic: ObservableObject { // Weather widget has multiple options, check if any are enabled return weatherOptions.showStatus || weatherOptions.showText || weatherOptions.showMedian || weatherOptions.showNextBlockFee case .price: - // Price widget has options, check if at least one trading pair is selected - return !priceOptions.selectedPairs.isEmpty + // Price widget always has a selected pair (single-select). + return true case .calculator, .suggestions: return false } @@ -158,7 +158,7 @@ class WidgetEditLogic: ObservableObject { } private func selectTradingPair(_ pairName: String) { - priceOptions.selectedPairs = [pairName] + priceOptions.selectedPair = pairName } func loadCurrentOptions() { diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index fa1db48ca..76ff00a6b 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -380,7 +380,7 @@ enum WidgetEditItemFactory { // CURRENCY section (single-select) items.append(sectionHeaderItem(key: "currency_header", title: t("widgets__price__currency"))) - let selectedPair = priceOptions.selectedPairs.first + let selectedPair = priceOptions.selectedPair for pair in tradingPairNames { let isSelected = selectedPair == pair items.append( diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index c3de7cd92..724faf2e1 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -50,7 +50,7 @@ struct PriceWidgetProvider: TimelineProvider { return } - let cached = PriceWidgetService.cachedPrices(pairs: options.selectedPairs, period: options.selectedPeriod) ?? [] + let cached = PriceWidgetService.cachedPrices(pairs: [options.selectedPair], period: options.selectedPeriod) ?? [] completion(PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: false)) } @@ -61,12 +61,12 @@ struct PriceWidgetProvider: TimelineProvider { let entry: PriceWidgetEntry do { let fresh = try await PriceWidgetService.fetchFreshPrices( - pairs: options.selectedPairs, + pairs: [options.selectedPair], period: options.selectedPeriod ) entry = PriceWidgetEntry(date: Date(), prices: fresh, options: options, showsError: false) } catch { - let cached = PriceWidgetService.cachedPrices(pairs: options.selectedPairs, period: options.selectedPeriod) ?? [] + let cached = PriceWidgetService.cachedPrices(pairs: [options.selectedPair], period: options.selectedPeriod) ?? [] entry = PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: cached.isEmpty) } @@ -108,8 +108,7 @@ struct PriceHomeScreenWidgetEntryView: View { } private var primaryPrice: PriceData? { - let preferred = entry.options.selectedPairs.first - if let preferred, let match = entry.prices.first(where: { $0.name == preferred }) { + if let match = entry.prices.first(where: { $0.name == entry.options.selectedPair }) { return match } return entry.prices.first From 51c2f4130e8083158419f766534db8d68c336039 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 10:52:06 -0300 Subject: [PATCH 28/60] fix: fallback to os widget options after remove in-app --- Bitkit/ViewModels/WidgetsViewModel.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 67a6a804e..ee0edad19 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -232,6 +232,10 @@ class WidgetsViewModel: ObservableObject { return options } + if type == .price, let priceOptions = PriceHomeScreenWidgetOptionsStore.load() as? T { + return priceOptions + } + // Return default options if none saved return getDefaultOptions(for: type) as! T } From afb421ad2f6ddf53c18971043380db0610357d9c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 11:10:09 -0300 Subject: [PATCH 29/60] fix: make chart height adaptable --- BitkitWidget/PriceHomeScreenWidget.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index 724faf2e1..55ab2b7c0 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -117,7 +117,7 @@ struct PriceHomeScreenWidgetEntryView: View { // MARK: - Compact (small widget — 163×192) private func compactLayout(data: PriceData) -> some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 0) { captionUpText(data.name) @@ -134,15 +134,17 @@ struct PriceHomeScreenWidgetEntryView: View { .widgetAccentable() } - chart(values: data.pastValues, isPositive: data.change.isPositive, height: 64) + Spacer(minLength: 8) + + chart(values: data.pastValues, isPositive: data.change.isPositive, idealHeight: 64) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - // MARK: - Wide (medium / large widget) + // MARK: - Wide (medium widget — 343×152) private func wideLayout(data: PriceData) -> some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 4) { HStack(alignment: .center, spacing: 16) { captionUpText("\(data.name) \(entry.options.selectedPeriod.rawValue)") @@ -158,7 +160,9 @@ struct PriceHomeScreenWidgetEntryView: View { priceText(data.price, size: 34, lineHeight: 34) } - chart(values: data.pastValues, isPositive: data.change.isPositive, height: 48) + Spacer(minLength: 4) + + chart(values: data.pastValues, isPositive: data.change.isPositive, idealHeight: 48, minHeight: 24) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @@ -181,13 +185,13 @@ struct PriceHomeScreenWidgetEntryView: View { .widgetAccentable() } - private func chart(values: [Double], isPositive: Bool, height: CGFloat) -> some View { + private func chart(values: [Double], isPositive: Bool, idealHeight: CGFloat, minHeight: CGFloat = 32) -> some View { PriceWidgetChart( values: values, isPositive: isPositive, renderingMode: widgetRenderingMode ) - .frame(height: height) + .frame(minHeight: minHeight, maxHeight: idealHeight) .widgetAccentable() } From 5be60140abacd71b66749b9c5daf499cded7ad99 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 13:31:14 -0300 Subject: [PATCH 30/60] feat: set backgroud color Gray7 --- Bitkit/Components/NavigationBar.swift | 19 ++++++++++++++----- .../Widgets/PriceWidgetPreviewView.swift | 4 +++- Bitkit/Views/Widgets/WidgetEditView.swift | 5 ++++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/Bitkit/Components/NavigationBar.swift b/Bitkit/Components/NavigationBar.swift index 36fb13dbb..787334442 100644 --- a/Bitkit/Components/NavigationBar.swift +++ b/Bitkit/Components/NavigationBar.swift @@ -7,6 +7,7 @@ struct NavigationBar: View { let title: String let showBackButton: Bool let showMenuButton: Bool + let showGradient: Bool let action: AnyView? let icon: String? let onBack: (() -> Void)? @@ -15,6 +16,7 @@ struct NavigationBar: View { title: String, showBackButton: Bool = true, showMenuButton: Bool = true, + showGradient: Bool = true, action: AnyView? = nil, icon: String? = nil, onBack: (() -> Void)? = nil @@ -22,6 +24,7 @@ struct NavigationBar: View { self.title = title self.showBackButton = showBackButton self.showMenuButton = showMenuButton + self.showGradient = showGradient self.action = action self.icon = icon self.onBack = onBack @@ -89,11 +92,17 @@ struct NavigationBar: View { } } .frame(height: 48) - .background(LinearGradient( - colors: [.black, .black.opacity(0)], - startPoint: .top, - endPoint: .bottom - )) + .background( + Group { + if showGradient { + LinearGradient( + colors: [.black, .black.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + } + } + ) .zIndex(.infinity) } } diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index afead9ce9..54d7bca05 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -43,7 +43,7 @@ struct PriceWidgetPreviewView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - NavigationBar(title: widgetName, showMenuButton: false) + NavigationBar(title: widgetName, showMenuButton: false, showGradient: false) VStack(alignment: .leading, spacing: 0) { BodyMText(widgetDescription, textColor: .textSecondary) @@ -73,6 +73,8 @@ struct PriceWidgetPreviewView: View { } .navigationBarHidden(true) .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray7.ignoresSafeArea()) .task { let options = currentOptions viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index e119ee310..4c3e81a88 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -59,7 +59,8 @@ struct WidgetEditView: View { VStack(alignment: .leading, spacing: 0) { NavigationBar( title: id == .price ? widget.name : t("widgets__widget__edit"), - showMenuButton: id != .price + showMenuButton: id != .price, + showGradient: id != .price ) .padding(.bottom, 16) @@ -112,6 +113,8 @@ struct WidgetEditView: View { } .navigationBarHidden(true) .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray7.ignoresSafeArea()) .onAppear { if editLogic == nil { let logic = WidgetEditLogic(widgetType: id, widgetsViewModel: widgets) From d60c84cc7299d43f1bf3fd40ab376c365a07801f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 06:33:06 -0300 Subject: [PATCH 31/60] feat: migrate news widget to design v61 and port OS widget --- .claude/scheduled_tasks.lock | 1 + Bitkit.xcodeproj/project.pbxproj | 3 + Bitkit/Components/Widgets/NewsWidget.swift | 116 +++++--- Bitkit/MainNavView.swift | 7 +- Bitkit/Models/NewsWidgetData.swift | 40 +++ Bitkit/Models/NewsWidgetOptions.swift | 14 + .../Localization/en.lproj/Localizable.strings | 4 + .../NewsHomeScreenWidgetOptionsStore.swift | 36 +++ Bitkit/Services/Widgets/NewsService.swift | 112 +++----- Bitkit/ViewModels/WidgetsViewModel.swift | 13 + .../Views/Widgets/NewsWidgetPreviewView.swift | 250 ++++++++++++++++ Bitkit/Views/Widgets/WidgetEditModels.swift | 40 +-- BitkitWidget/BitkitWidget.swift | 1 + BitkitWidget/NewsHomeScreenWidget.swift | 266 ++++++++++++++++++ BitkitWidget/NewsWidgetService.swift | 60 ++++ 15 files changed, 836 insertions(+), 127 deletions(-) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 Bitkit/Models/NewsWidgetData.swift create mode 100644 Bitkit/Models/NewsWidgetOptions.swift create mode 100644 Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift create mode 100644 Bitkit/Views/Widgets/NewsWidgetPreviewView.swift create mode 100644 BitkitWidget/NewsHomeScreenWidget.swift create mode 100644 BitkitWidget/NewsWidgetService.swift diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 000000000..ae98bc612 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"53eebf98-950c-4963-804d-ef8de7c14fe4","pid":5145,"procStart":"Wed May 6 16:43:05 2026","acquiredAt":1778087089693} \ No newline at end of file diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 799da4457..15e22deb6 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -173,8 +173,11 @@ Fonts/InterTight-Regular.ttf, Constants/WidgetEnv.swift, Fonts/InterTight-SemiBold.ttf, + Models/NewsWidgetData.swift, + Models/NewsWidgetOptions.swift, Models/PriceWidgetData.swift, Models/PriceWidgetOptions.swift, + Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift, Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift, Styles/Colors.swift, Styles/Fonts.swift, diff --git a/Bitkit/Components/Widgets/NewsWidget.swift b/Bitkit/Components/Widgets/NewsWidget.swift index 1a64e748d..35295183b 100644 --- a/Bitkit/Components/Widgets/NewsWidget.swift +++ b/Bitkit/Components/Widgets/NewsWidget.swift @@ -1,27 +1,13 @@ import SwiftUI -/// Options for configuring the NewsWidget -struct NewsWidgetOptions: Codable, Equatable { - var showDate: Bool = true - var showTitle: Bool = true - var showSource: Bool = true -} - -/// A widget that displays a news article +/// A widget that displays a news article (Figma v61). struct NewsWidget: View { - /// Configuration options for the widget var options: NewsWidgetOptions = .init() - - /// Flag indicating if the widget is in editing mode var isEditing: Bool = false - - /// Callback to signal when editing should end var onEditingEnd: (() -> Void)? - /// View model for handling news data @StateObject private var viewModel = NewsViewModel.shared - /// Initialize the widget init( options: NewsWidgetOptions = NewsWidgetOptions(), isEditing: Bool = false, @@ -38,40 +24,92 @@ struct NewsWidget: View { isEditing: isEditing, onEditingEnd: onEditingEnd ) { - VStack(spacing: 0) { - if viewModel.isLoading { - WidgetContentBuilder.loadingView() - } else if viewModel.error != nil { - WidgetContentBuilder.errorView(t("widgets__news__error")) - } else if let data = viewModel.widgetData { - if options.showDate { - BodyMText(data.timeAgo, textColor: .textPrimary) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 16) + content + .contentShape(Rectangle()) + .onTapGesture { + if !isEditing, let data = viewModel.widgetData, let url = URL(string: data.link) { + UIApplication.shared.open(url) } + } + } + .onAppear { + viewModel.startUpdates() + } + } - if options.showTitle { - TitleText(data.title) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - } + @ViewBuilder + private var content: some View { + if viewModel.isLoading && viewModel.widgetData == nil { + WidgetContentBuilder.loadingView() + } else if viewModel.error != nil { + WidgetContentBuilder.errorView(t("widgets__news__error")) + } else if let data = viewModel.widgetData { + NewsWidgetWideContent(data: data, options: options) + } + } +} + +// MARK: - Wide layout (in-app + 343-wide carousel page) + +struct NewsWidgetWideContent: View { + let data: WidgetData + let options: NewsWidgetOptions + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if options.showTitle { + TitleText(data.title) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .leading) + } + if options.showSource || options.showDate { + HStack(alignment: .center, spacing: 8) { if options.showSource { - WidgetContentBuilder.sourceRow(source: data.publisher) + BodySSBText(data.publisher, textColor: .brandAccent) + .lineLimit(1) + } + Spacer(minLength: 0) + if options.showDate { + BodySSBText(data.timeAgo, textColor: .textSecondary) + .lineLimit(1) } } + .frame(maxWidth: .infinity) } - .contentShape(Rectangle()) - .onTapGesture { - if !isEditing, let data = viewModel.widgetData, let url = URL(string: data.link) { - UIApplication.shared.open(url) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Compact layout (small carousel preview + 163×192 OS widget) + +struct NewsWidgetCompactContent: View { + let data: WidgetData + let options: NewsWidgetOptions + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if options.showTitle { + TitleText(data.title) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer(minLength: 8) + + if options.showDate { + HStack { + Spacer(minLength: 0) + BodySSBText(data.timeAgo, textColor: .textSecondary) + .lineLimit(1) } } } - .onAppear { - viewModel.startUpdates() - } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.gray6) + .cornerRadius(16) } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 2a17eaa70..3bcb39cd6 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -432,9 +432,12 @@ struct MainNavView: View { case .widgetsIntro: WidgetsIntroView() case .widgetsList: WidgetsListView() case let .widgetDetail(widgetType): - if widgetType == .price { + switch widgetType { + case .price: PriceWidgetPreviewView() - } else { + case .news: + NewsWidgetPreviewView() + default: WidgetDetailView(id: widgetType) } case let .widgetEdit(widgetType): WidgetEditView(id: widgetType) diff --git a/Bitkit/Models/NewsWidgetData.swift b/Bitkit/Models/NewsWidgetData.swift new file mode 100644 index 000000000..1db970135 --- /dev/null +++ b/Bitkit/Models/NewsWidgetData.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Persistable representation of a news article shared between the main app and the widget extension via App Group. +struct CachedNewsArticle: Codable, Equatable { + let title: String + let publisher: String + let link: String + let publishedDate: String + let publishedEpoch: Int +} + +/// Cache reader/writer used by both the main app and the widget extension. +enum NewsWidgetCache { + static let appGroupSuiteName = "group.bitkit" + private static let topArticlesKey = "news_widget_top_articles_v1" + private static let legacyStandardKey = "news_widget_cache" + + private static func defaults() -> UserDefaults { + UserDefaults(suiteName: appGroupSuiteName) ?? .standard + } + + static func saveTop(_ articles: [CachedNewsArticle]) { + guard let encoded = try? JSONEncoder().encode(articles) else { return } + defaults().set(encoded, forKey: topArticlesKey) + } + + static func loadTop() -> [CachedNewsArticle] { + guard let data = defaults().data(forKey: topArticlesKey), + let decoded = try? JSONDecoder().decode([CachedNewsArticle].self, from: data) + else { + return [] + } + return decoded + } + + /// One-time cleanup of the pre-App-Group single-`WidgetData` cache. + static func legacyDropStandardSuiteCache() { + UserDefaults.standard.removeObject(forKey: legacyStandardKey) + } +} diff --git a/Bitkit/Models/NewsWidgetOptions.swift b/Bitkit/Models/NewsWidgetOptions.swift new file mode 100644 index 000000000..4497b6b50 --- /dev/null +++ b/Bitkit/Models/NewsWidgetOptions.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Options for configuring the in-app and home-screen news widgets (shared via App Group). +struct NewsWidgetOptions: Codable, Equatable { + var showDate: Bool = true + var showTitle: Bool = true + var showSource: Bool = true + + init(showDate: Bool = true, showTitle: Bool = true, showSource: Bool = true) { + self.showDate = showDate + self.showTitle = showTitle + self.showSource = showSource + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index afad97a20..c32f9de47 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1404,6 +1404,10 @@ "widgets__news__name" = "Bitcoin Headlines"; "widgets__news__description" = "Read the latest & greatest Bitcoin headlines from various news sites."; "widgets__news__error" = "Couldn\'t get the latest news"; +"widgets__news__size_small" = "Small"; +"widgets__news__size_wide" = "Wide"; +"widgets__news__widget_settings" = "Widget Settings"; +"widgets__news__content_header" = "Content"; "widgets__blocks__name" = "Bitcoin Blocks"; "widgets__blocks__description" = "Examine various statistics on newly mined Bitcoin Blocks."; "widgets__blocks__error" = "Couldn\'t get blocks data"; diff --git a/Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift new file mode 100644 index 000000000..043d161dd --- /dev/null +++ b/Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift @@ -0,0 +1,36 @@ +import Foundation +import WidgetKit + +/// Mirrors in-app news widget options into the App Group so the WidgetKit extension can read them, +/// and centralizes the WidgetKit reload trigger for the news home-screen widget. +enum NewsHomeScreenWidgetOptionsStore { + /// WidgetKit `kind` for the home-screen news widget (must match `BitkitNewsWidget`). + static let newsHomeScreenWidgetKind = "BitkitNewsWidget" + + private static let suiteName = "group.bitkit" + private static let key = "home_screen_news_widget_options_v1" + + static func save(_ options: NewsWidgetOptions) { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = try? JSONEncoder().encode(options) + else { return } + defaults.set(data, forKey: key) + } + + static func load() -> NewsWidgetOptions { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = defaults.data(forKey: key), + let options = try? JSONDecoder().decode(NewsWidgetOptions.self, from: data) + else { + return NewsWidgetOptions() + } + 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: newsHomeScreenWidgetKind) + } +} diff --git a/Bitkit/Services/Widgets/NewsService.swift b/Bitkit/Services/Widgets/NewsService.swift index e02681ab3..2d6d9aca7 100644 --- a/Bitkit/Services/Widgets/NewsService.swift +++ b/Bitkit/Services/Widgets/NewsService.swift @@ -3,12 +3,12 @@ import Foundation /// Service for fetching and caching news articles class NewsService { static let shared = NewsService() - private let cache = UserDefaults.standard - private let cacheKey = "news_widget_cache" private let baseUrl = "https://feeds.synonym.to/news-feed/api" private let refreshInterval: TimeInterval = 2 * 60 // 2 minutes - private init() {} + private init() { + NewsWidgetCache.legacyDropStandardSuiteCache() + } /// Fetches articles from the news API /// - Returns: Array of articles @@ -20,7 +20,6 @@ class NewsService { let (data, response) = try await URLSession.shared.data(from: url) - // Validate HTTP response guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } @@ -29,39 +28,18 @@ class NewsService { throw URLError(.badServerResponse) } - do { - let decoder = JSONDecoder() - return try decoder.decode([Article].self, from: data) - } catch { - throw error - } + return try JSONDecoder().decode([Article].self, from: data) } - /// Caches widget data to UserDefaults - /// - Parameter data: Widget data to cache - func cacheData(_ data: WidgetData) { - do { - let encoder = JSONEncoder() - let encoded = try encoder.encode(data) - cache.set(encoded, forKey: cacheKey) - } catch { - // Handle silently - } - } - - /// Retrieves cached widget data - /// - Returns: Widget data if available + /// Retrieves a cached widget data view by selecting a random article from the App Group cache. func getCachedData() -> WidgetData? { - guard let data = cache.data(forKey: cacheKey) else { - return nil - } - - do { - let decoder = JSONDecoder() - return try decoder.decode(WidgetData.self, from: data) - } catch { - return nil - } + guard let article = NewsWidgetCache.loadTop().randomElement() else { return nil } + return WidgetData( + title: article.title, + timeAgo: timeAgo(from: article.publishedDate), + link: article.link, + publisher: article.publisher + ) } /// Converts a date string to a human-readable time ago format @@ -83,60 +61,60 @@ class NewsService { return relativeFormatter.localizedString(for: date, relativeTo: Date()) } + /// Fetches the top 10 most recent articles, persists them to the App Group cache, + /// and triggers a home-screen widget reload. + @discardableResult + func fetchTopArticles() async throws -> [CachedNewsArticle] { + let articles = try await fetchArticles() + let top = articles + .sorted { $0.published > $1.published } + .prefix(10) + .map { article in + CachedNewsArticle( + title: article.title, + publisher: article.publisher.title, + link: article.comments ?? article.link, + publishedDate: article.publishedDate, + publishedEpoch: article.published + ) + } + + NewsWidgetCache.saveTop(top) + NewsHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() + + return top + } + /// Fetches widget data using stale-while-revalidate strategy /// - Parameter returnCachedImmediately: If true, returns cached data immediately if available /// - Returns: Widget data /// - Throws: URLError or decoding error @discardableResult func fetchWidgetData(returnCachedImmediately: Bool = true) async throws -> WidgetData { - // If we want cached data and it exists, return it immediately if returnCachedImmediately, let cachedData = getCachedData() { - // Start fresh fetch in background to update cache (don't await) + // Refresh in background; cache is updated automatically. Task { do { - try await fetchFreshData() - // Cache will be updated automatically in fetchFreshData + try await fetchTopArticles() } catch { - // Silent failure for background updates print("Background news data update failed: \(error)") } } return cachedData } - // No cache available or cache not requested - fetch fresh data - return try await fetchFreshData() - } - - /// Fetches fresh data from API (always hits the network) - @discardableResult - private func fetchFreshData() async throws -> WidgetData { - let articles = try await fetchArticles() - - // Get a random article from the last 10 - let recentArticles = - articles - .sorted { $0.published > $1.published } - .prefix(10) - - guard let article = recentArticles.randomElement() else { + let top = try await fetchTopArticles() + guard let article = top.randomElement() else { Logger.error("No articles available after filtering") throw URLError(.cannotParseResponse) } - let timeAgoString = timeAgo(from: article.publishedDate) - - let widgetData = WidgetData( + return WidgetData( title: article.title, - timeAgo: timeAgoString, - link: article.comments ?? article.link, - publisher: article.publisher.title + timeAgo: timeAgo(from: article.publishedDate), + link: article.link, + publisher: article.publisher ) - - // Cache the data - cacheData(widgetData) - - return widgetData } } @@ -201,7 +179,7 @@ struct Publisher: Codable { let image: String? } -/// Widget data model for caching +/// Widget data model used by the in-app news widget UI. struct WidgetData: Codable { let title: String let timeAgo: String diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index ee0edad19..3a29f381e 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -236,6 +236,10 @@ class WidgetsViewModel: ObservableObject { return priceOptions } + if type == .news, let newsOptions = NewsHomeScreenWidgetOptionsStore.load() as? T { + return newsOptions + } + // Return default options if none saved return getDefaultOptions(for: type) as! T } @@ -306,6 +310,7 @@ class WidgetsViewModel: ObservableObject { persistSavedWidgets() } syncPriceOptionsToHomeScreenWidget() + syncNewsOptionsToHomeScreenWidget() } private func persistSavedWidgets() { @@ -316,6 +321,7 @@ class WidgetsViewModel: ObservableObject { print("Failed to persist widgets: \(error)") } syncPriceOptionsToHomeScreenWidget() + syncNewsOptionsToHomeScreenWidget() } /// Keeps the home-screen WidgetKit price widget in sync with in-app price widget options (App Group). @@ -324,4 +330,11 @@ class WidgetsViewModel: ObservableObject { PriceHomeScreenWidgetOptionsStore.save(options) PriceHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } + + /// Keeps the home-screen WidgetKit news widget in sync with in-app news widget options (App Group). + private func syncNewsOptionsToHomeScreenWidget() { + let options: NewsWidgetOptions = getOptions(for: .news, as: NewsWidgetOptions.self) + NewsHomeScreenWidgetOptionsStore.save(options) + NewsHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() + } } diff --git a/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift b/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift new file mode 100644 index 000000000..0182d3d46 --- /dev/null +++ b/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift @@ -0,0 +1,250 @@ +import SwiftUI + +/// Preview screen for the Bitcoin Headlines widget. +struct NewsWidgetPreviewView: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + + @StateObject private var viewModel = NewsViewModel.shared + + @State private var carouselPage: Int = 0 + @State private var showDeleteAlert = false + + private let widgetType: WidgetType = .news + + private var widgetName: String { + t("widgets__news__name") + } + + private var widgetDescription: String { + t("widgets__news__description") + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(widgetType) + } + + private var hasCustomOptions: Bool { + widgets.hasCustomOptions(for: widgetType) + } + + private var currentOptions: NewsWidgetOptions { + widgets.getOptions(for: widgetType, as: NewsWidgetOptions.self) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + NavigationBar(title: widgetName, showMenuButton: false, showGradient: false) + + VStack(alignment: .leading, spacing: 0) { + BodyMText(widgetDescription, textColor: .textSecondary) + .padding(.bottom, 16) + + Divider().background(Color.white.opacity(0.1)) + + widgetSettingsRow + + Divider().background(Color.white.opacity(0.1)) + } + + VStack(spacing: 16) { + Spacer(minLength: 0) + + carousel + + Spacer(minLength: 0) + + sizeLabel + + pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + buttonsRow + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray7.ignoresSafeArea()) + .onAppear { + viewModel.startUpdates() + } + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteAlert, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } + Button(t("common__delete_yes"), role: .destructive) { onDelete() } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": widgetName])) + } + ) + } + + // MARK: - Widget Settings cell + + private var widgetSettingsRow: some View { + Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { + HStack(alignment: .center, spacing: 0) { + BodyMText(t("widgets__news__widget_settings"), textColor: .textPrimary) + + Spacer() + + BodyMText( + hasCustomOptions + ? t("widgets__widget__edit_custom") + : t("widgets__widget__edit_default"), + textColor: .textSecondary + ) + + Image("chevron") + .resizable() + .foregroundColor(.textSecondary) + .frame(width: 24, height: 24) + .padding(.leading, 5) + } + .frame(maxWidth: .infinity, minHeight: 51) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("WidgetEdit") + } + + // MARK: - Carousel + + private var carousel: some View { + TabView(selection: $carouselPage) { + compactPage + .tag(0) + + widePage + .tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(height: 320) + } + + private var compactPage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = viewModel.widgetData { + NewsWidgetCompactContent(data: data, options: currentOptions) + } else { + placeholderCompact + } + } + .frame(width: 163, height: 192) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private var widePage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = viewModel.widgetData { + NewsWidgetWideContent(data: data, options: currentOptions) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + placeholderWide + } + } + .frame(maxWidth: .infinity) + Spacer(minLength: 0) + } + } + + private var placeholderCompact: some View { + Color.gray6 + .cornerRadius(16) + .overlay(ProgressView()) + } + + private var placeholderWide: some View { + Color.gray6 + .cornerRadius(16) + .frame(height: 118) + .overlay(ProgressView()) + } + + // MARK: - Size label & page indicator + + private var sizeLabel: some View { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__news__size_small") + : t("widgets__news__size_wide"), + textColor: .textSecondary + ) + .textCase(.uppercase) + Spacer() + } + } + + private var pageIndicator: some View { + HStack(spacing: 8) { + Spacer() + ForEach(0 ..< 2, id: \.self) { index in + Circle() + .fill(carouselPage == index ? Color.brandAccent : Color.white.opacity(0.32)) + .frame(width: 8, height: 8) + } + Spacer() + } + } + + // MARK: - Buttons + + private var buttonsRow: some View { + HStack(spacing: 16) { + if isWidgetSaved { + CustomButton( + title: t("common__delete"), + variant: .secondary, + size: .large, + shouldExpand: true + ) { + showDeleteAlert = true + } + .accessibilityIdentifier("WidgetDelete") + } + + CustomButton( + title: t("widgets__widget__save_widget"), + variant: .primary, + size: .large, + shouldExpand: true, + action: onSave + ) + .accessibilityIdentifier("WidgetSave") + } + } + + // MARK: - Actions + + private func onSave() { + widgets.saveWidget(widgetType) + navigation.reset() + } + + private func onDelete() { + widgets.deleteWidget(widgetType) + navigation.reset() + } +} + +#Preview { + NavigationStack { + NewsWidgetPreviewView() + .environmentObject(NavigationViewModel()) + .environmentObject(WidgetsViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 76ff00a6b..b7a30e3e6 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -307,17 +307,9 @@ enum WidgetEditItemFactory { ) -> [WidgetEditItem] { var items: [WidgetEditItem] = [] - if let data = newsViewModel.widgetData { - items.append( - WidgetEditItem( - key: "showDate", - type: .toggleItem, - titleView: AnyView(BodyMText(data.timeAgo, textColor: .textPrimary)), - valueView: nil, - isChecked: newsOptions.showDate - ) - ) + items.append(sectionHeaderItem(key: "news_content_header", title: t("widgets__news__content_header"))) + if let data = newsViewModel.widgetData { items.append( WidgetEditItem( key: "showTitle", @@ -332,28 +324,28 @@ enum WidgetEditItemFactory { WidgetEditItem( key: "showSource", type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText(data.publisher, textColor: .textSecondary)), + titleView: AnyView(BodySSBText(data.publisher, textColor: .brandAccent)), + valueView: nil, isChecked: newsOptions.showSource ) ) - } else { - // Fallback when no data is available + items.append( WidgetEditItem( key: "showDate", type: .toggleItem, - titleView: AnyView(BodyMText("13 hours ago", textColor: .textPrimary)), + titleView: AnyView(BodySSBText(data.timeAgo, textColor: .textSecondary)), valueView: nil, isChecked: newsOptions.showDate ) ) - + } else { + // Fallback when no data is available items.append( WidgetEditItem( key: "showTitle", type: .staticItem, - titleView: AnyView(TitleText("Exodus Launches XO Pay, An In-App Bitcoin And Crypto Purchase Solution")), + titleView: AnyView(TitleText("How Bitcoin changed El Salvador in more ways...")), valueView: nil, isChecked: true // Static items are always shown ) @@ -363,11 +355,21 @@ enum WidgetEditItemFactory { WidgetEditItem( key: "showSource", type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("Bitcoin Magazine", textColor: .textSecondary)), + titleView: AnyView(BodySSBText("bitcoinmagazine.com", textColor: .brandAccent)), + valueView: nil, isChecked: newsOptions.showSource ) ) + + items.append( + WidgetEditItem( + key: "showDate", + type: .toggleItem, + titleView: AnyView(BodySSBText("1 min ago", textColor: .textSecondary)), + valueView: nil, + isChecked: newsOptions.showDate + ) + ) } return items diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift index 737864ecf..ba9bbdd06 100644 --- a/BitkitWidget/BitkitWidget.swift +++ b/BitkitWidget/BitkitWidget.swift @@ -5,5 +5,6 @@ import WidgetKit struct BitkitWidgetBundle: WidgetBundle { var body: some Widget { BitkitPriceWidget() + BitkitNewsWidget() } } diff --git a/BitkitWidget/NewsHomeScreenWidget.swift b/BitkitWidget/NewsHomeScreenWidget.swift new file mode 100644 index 000000000..0e0d894d0 --- /dev/null +++ b/BitkitWidget/NewsHomeScreenWidget.swift @@ -0,0 +1,266 @@ +import SwiftUI +import WidgetKit + +// MARK: - Entry + +struct NewsWidgetEntry: TimelineEntry { + let date: Date + let article: CachedNewsArticle? + let timeAgo: String + let options: NewsWidgetOptions + /// True when no fresh data could be fetched and there is nothing in cache to fall back to. + let showsError: Bool +} + +// MARK: - Helpers + +private enum NewsWidgetEntryBuilder { + static func relativeTime(from dateString: String) -> String { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" + guard let date = formatter.date(from: dateString) else { return "" } + + let relative = RelativeDateTimeFormatter() + relative.locale = Locale.current + relative.dateTimeStyle = .named + return relative.localizedString(for: date, relativeTo: Date()) + } +} + +// MARK: - Timeline Provider + +struct NewsWidgetProvider: TimelineProvider { + /// Stable mock for widget gallery / placeholder snapshots. + private static let mockArticle = CachedNewsArticle( + title: "How Bitcoin changed El Salvador in more ways than one", + publisher: "bitcoinmagazine.com", + link: "https://bitcoinmagazine.com", + publishedDate: "Mon, 01 Jan 2024 12:00:00 +0000", + publishedEpoch: 1_704_110_400 + ) + + private static let mockEntry = NewsWidgetEntry( + date: Date(), + article: mockArticle, + timeAgo: "21 min ago", + options: NewsWidgetOptions(), + showsError: false + ) + + func placeholder(in _: Context) -> NewsWidgetEntry { + Self.mockEntry + } + + func getSnapshot(in context: Context, completion: @escaping (NewsWidgetEntry) -> Void) { + let options = NewsHomeScreenWidgetOptionsStore.load() + + if context.isPreview { + completion(NewsWidgetEntry( + date: Self.mockEntry.date, + article: Self.mockArticle, + timeAgo: Self.mockEntry.timeAgo, + options: options, + showsError: false + )) + return + } + + let cached = NewsWidgetService.cachedTopArticles() + let pick = cached.randomElement() + completion(NewsWidgetEntry( + date: Date(), + article: pick, + timeAgo: pick.map { NewsWidgetEntryBuilder.relativeTime(from: $0.publishedDate) } ?? "", + options: options, + showsError: false + )) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let options = NewsHomeScreenWidgetOptionsStore.load() + + Task { + let entry: NewsWidgetEntry + do { + let fresh = try await NewsWidgetService.fetchFreshTopArticles() + if let pick = fresh.randomElement() { + entry = NewsWidgetEntry( + date: Date(), + article: pick, + timeAgo: NewsWidgetEntryBuilder.relativeTime(from: pick.publishedDate), + options: options, + showsError: false + ) + } else { + entry = NewsWidgetEntry(date: Date(), article: nil, timeAgo: "", options: options, showsError: true) + } + } catch { + let cached = NewsWidgetService.cachedTopArticles() + if let pick = cached.randomElement() { + entry = NewsWidgetEntry( + date: Date(), + article: pick, + timeAgo: NewsWidgetEntryBuilder.relativeTime(from: pick.publishedDate), + options: options, + showsError: false + ) + } else { + entry = NewsWidgetEntry(date: Date(), article: nil, timeAgo: "", options: options, showsError: true) + } + } + + 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 NewsHomeScreenWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode + + var entry: NewsWidgetProvider.Entry + + var body: some View { + Group { + if let url = articleURL { + Link(destination: url) { content } + } else { + content + } + } + .containerBackground(for: .widget) { backgroundView } + } + + private var articleURL: URL? { + guard let link = entry.article?.link else { return nil } + return URL(string: link) + } + + @ViewBuilder + private var content: some View { + if entry.showsError { + errorView + } else if let article = entry.article { + switch widgetFamily { + case .systemSmall: + compactLayout(article: article) + default: + wideLayout(article: article) + } + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + + // MARK: - Compact (small widget — 163×192) + + private func compactLayout(article: CachedNewsArticle) -> some View { + VStack(alignment: .leading, spacing: 0) { + titleText(article.title) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer(minLength: 8) + + if entry.options.showDate { + HStack { + Spacer(minLength: 0) + Text(entry.timeAgo) + .font(Fonts.semiBold(size: 13)) + .tracking(0.4) + .foregroundColor(secondaryTextColor) + .lineLimit(1) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Wide (medium widget — 343×118) + + private func wideLayout(article: CachedNewsArticle) -> some View { + VStack(alignment: .leading, spacing: 16) { + titleText(article.title) + .frame(maxWidth: .infinity, alignment: .leading) + + if entry.options.showSource || entry.options.showDate { + HStack(alignment: .center, spacing: 8) { + if entry.options.showSource { + Text(article.publisher) + .font(Fonts.semiBold(size: 13)) + .tracking(0.4) + .foregroundColor(sourceTextColor) + .lineLimit(1) + } + Spacer(minLength: 0) + if entry.options.showDate { + Text(entry.timeAgo) + .font(Fonts.semiBold(size: 13)) + .tracking(0.4) + .foregroundColor(secondaryTextColor) + .lineLimit(1) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Sub-views + + private func titleText(_ value: String) -> some View { + Text(value) + .font(Fonts.bold(size: 22)) + .foregroundColor(titleTextColor) + .lineLimit(4) + .minimumScaleFactor(0.85) + .widgetAccentable() + } + + private var errorView: some View { + Text("Couldn’t load headlines.") + .font(Fonts.regular(size: 13)) + .foregroundColor(secondaryTextColor) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Colors + + private var backgroundView: some View { + widgetRenderingMode == .fullColor ? Color.gray6 : Color.clear + } + + private var titleTextColor: Color { + widgetRenderingMode == .fullColor ? .white : .primary + } + + private var sourceTextColor: Color { + guard widgetRenderingMode == .fullColor else { return .primary } + return .brandAccent + } + + private var secondaryTextColor: Color { + widgetRenderingMode == .fullColor ? .white.opacity(0.64) : .secondary + } +} + +// MARK: - Widget Configuration + +struct BitkitNewsWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration( + kind: NewsHomeScreenWidgetOptionsStore.newsHomeScreenWidgetKind, + provider: NewsWidgetProvider() + ) { entry in + NewsHomeScreenWidgetEntryView(entry: entry) + } + .configurationDisplayName("Bitcoin Headlines") + .description("Latest Bitcoin news headlines, mirroring the in-app headlines widget.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} diff --git a/BitkitWidget/NewsWidgetService.swift b/BitkitWidget/NewsWidgetService.swift new file mode 100644 index 000000000..f97f3e2b5 --- /dev/null +++ b/BitkitWidget/NewsWidgetService.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Slim news fetcher used inside the WidgetKit extension. +/// +/// Reads cached `[CachedNewsArticle]` from the App Group (written by the main app's `NewsService`) +/// and falls back to a direct network fetch when the cache is empty or stale. The cache itself +/// is owned by the main app; this service intentionally does not write back to it. +enum NewsWidgetService { + enum FetchError: Error { + case invalidURL + case noArticlesAvailable + } + + private static let baseUrl = "https://feeds.synonym.to/news-feed/api" + + static func cachedTopArticles() -> [CachedNewsArticle] { + NewsWidgetCache.loadTop() + } + + static func fetchFreshTopArticles() async throws -> [CachedNewsArticle] { + guard let url = URL(string: "\(baseUrl)/articles") else { throw FetchError.invalidURL } + + let (data, _) = try await URLSession.shared.data(from: url) + let articles = try JSONDecoder().decode([WireArticle].self, from: data) + + let top = articles + .sorted { $0.published > $1.published } + .prefix(10) + .map { wire in + CachedNewsArticle( + title: wire.title, + publisher: wire.publisher.title, + link: wire.comments ?? wire.link, + publishedDate: wire.publishedDate, + publishedEpoch: wire.published + ) + } + + guard !top.isEmpty else { throw FetchError.noArticlesAvailable } + return top + } +} + +// MARK: - Wire Models + +/// Local copy to keep the widget extension's footprint small (mirrors `Article` in main app). +private struct WireArticle: Codable { + let title: String + let published: Int + let publishedDate: String + let link: String + let comments: String? + let publisher: WirePublisher +} + +private struct WirePublisher: Codable { + let title: String + let link: String + let image: String? +} From ab5b8d58d5d9b3fa4c4fe661bc6cd8d33d4f1015 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 06:37:07 -0300 Subject: [PATCH 32/60] fix: push source text to bottom --- BitkitWidget/NewsHomeScreenWidget.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/BitkitWidget/NewsHomeScreenWidget.swift b/BitkitWidget/NewsHomeScreenWidget.swift index 0e0d894d0..40bdeb56a 100644 --- a/BitkitWidget/NewsHomeScreenWidget.swift +++ b/BitkitWidget/NewsHomeScreenWidget.swift @@ -184,10 +184,12 @@ struct NewsHomeScreenWidgetEntryView: View { // MARK: - Wide (medium widget — 343×118) private func wideLayout(article: CachedNewsArticle) -> some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 0) { titleText(article.title) .frame(maxWidth: .infinity, alignment: .leading) + Spacer(minLength: 8) + if entry.options.showSource || entry.options.showDate { HStack(alignment: .center, spacing: 8) { if entry.options.showSource { From 66f82ff1f69e05ca6b735265fff771bb95f865c8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 06:40:53 -0300 Subject: [PATCH 33/60] refactor: extract articles url to a shared files --- Bitkit/Constants/WidgetEnv.swift | 2 ++ Bitkit/Services/Widgets/NewsService.swift | 3 +-- BitkitWidget/NewsWidgetService.swift | 4 +--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Bitkit/Constants/WidgetEnv.swift b/Bitkit/Constants/WidgetEnv.swift index 1c7e30320..19eb7e1ae 100644 --- a/Bitkit/Constants/WidgetEnv.swift +++ b/Bitkit/Constants/WidgetEnv.swift @@ -7,4 +7,6 @@ import Foundation /// 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" + static let newsFeedBaseUrl = "https://feeds.synonym.to/news-feed/api" + static let newsFeedArticlesUrl = "\(newsFeedBaseUrl)/articles" } diff --git a/Bitkit/Services/Widgets/NewsService.swift b/Bitkit/Services/Widgets/NewsService.swift index 2d6d9aca7..bc1b1db38 100644 --- a/Bitkit/Services/Widgets/NewsService.swift +++ b/Bitkit/Services/Widgets/NewsService.swift @@ -3,7 +3,6 @@ import Foundation /// Service for fetching and caching news articles class NewsService { static let shared = NewsService() - private let baseUrl = "https://feeds.synonym.to/news-feed/api" private let refreshInterval: TimeInterval = 2 * 60 // 2 minutes private init() { @@ -14,7 +13,7 @@ class NewsService { /// - Returns: Array of articles /// - Throws: URLError or decoding error func fetchArticles() async throws -> [Article] { - guard let url = URL(string: "\(baseUrl)/articles") else { + guard let url = URL(string: WidgetEnv.newsFeedArticlesUrl) else { throw URLError(.badURL) } diff --git a/BitkitWidget/NewsWidgetService.swift b/BitkitWidget/NewsWidgetService.swift index f97f3e2b5..c5246b05d 100644 --- a/BitkitWidget/NewsWidgetService.swift +++ b/BitkitWidget/NewsWidgetService.swift @@ -11,14 +11,12 @@ enum NewsWidgetService { case noArticlesAvailable } - private static let baseUrl = "https://feeds.synonym.to/news-feed/api" - static func cachedTopArticles() -> [CachedNewsArticle] { NewsWidgetCache.loadTop() } static func fetchFreshTopArticles() async throws -> [CachedNewsArticle] { - guard let url = URL(string: "\(baseUrl)/articles") else { throw FetchError.invalidURL } + guard let url = URL(string: WidgetEnv.newsFeedArticlesUrl) else { throw FetchError.invalidURL } let (data, _) = try await URLSession.shared.data(from: url) let articles = try JSONDecoder().decode([WireArticle].self, from: data) From 78b4dcd7fdf56d4d9fcd76ebf5ba098b95e51c2e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 07:00:44 -0300 Subject: [PATCH 34/60] feat: open browser on widget click --- Bitkit/MainNavView.swift | 6 ++++++ BitkitWidget/NewsHomeScreenWidget.swift | 1 + 2 files changed, 7 insertions(+) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 3bcb39cd6..ba6c2e3f4 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -246,6 +246,12 @@ struct MainNavView: View { Task { Logger.info("Received deeplink: \(url.absoluteString)") + // Web URLs from widgets (e.g. news article tap) bypass payment handling + if let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { + await UIApplication.shared.open(url) + return + } + if let callback = PubkyRingAuthCallback.parse(url: url) { let handlingResult = await pubkyProfile.handleAuthCallback(callback) diff --git a/BitkitWidget/NewsHomeScreenWidget.swift b/BitkitWidget/NewsHomeScreenWidget.swift index 40bdeb56a..3cb1d0fe8 100644 --- a/BitkitWidget/NewsHomeScreenWidget.swift +++ b/BitkitWidget/NewsHomeScreenWidget.swift @@ -133,6 +133,7 @@ struct NewsHomeScreenWidgetEntryView: View { content } } + .widgetURL(articleURL) .containerBackground(for: .widget) { backgroundView } } From 12fe6e6c299f25af65862bdb0cac9ab196b72a0d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 07:38:55 -0300 Subject: [PATCH 35/60] doc: changelog entry --- changelog.d/next/546.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/546.added.md diff --git a/changelog.d/next/546.added.md b/changelog.d/next/546.added.md new file mode 100644 index 000000000..7d9db63b2 --- /dev/null +++ b/changelog.d/next/546.added.md @@ -0,0 +1 @@ +Added a Bitcoin Headlines home-screen widget and redesigned the in-app Headlines widget, preview, and edit screens to match Figma v61. From 667e544262a634ce6d737c1c0c49fa7b31c6385b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 07:53:44 -0300 Subject: [PATCH 36/60] fix: small and medium sizes displaying different random url --- BitkitWidget/NewsHomeScreenWidget.swift | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/BitkitWidget/NewsHomeScreenWidget.swift b/BitkitWidget/NewsHomeScreenWidget.swift index 3cb1d0fe8..ac787a524 100644 --- a/BitkitWidget/NewsHomeScreenWidget.swift +++ b/BitkitWidget/NewsHomeScreenWidget.swift @@ -15,6 +15,8 @@ struct NewsWidgetEntry: TimelineEntry { // MARK: - Helpers private enum NewsWidgetEntryBuilder { + static let refreshInterval: TimeInterval = 15 * 60 + static func relativeTime(from dateString: String) -> String { let formatter = DateFormatter() formatter.locale = Locale.current @@ -26,6 +28,13 @@ private enum NewsWidgetEntryBuilder { relative.dateTimeStyle = .named return relative.localizedString(for: date, relativeTo: Date()) } + + static func currentArticle(from articles: [CachedNewsArticle], at date: Date = Date()) -> CachedNewsArticle? { + guard !articles.isEmpty else { return nil } + let bucket = Int(date.timeIntervalSince1970 / refreshInterval) + let index = abs(bucket) % articles.count + return articles[index] + } } // MARK: - Timeline Provider @@ -67,7 +76,7 @@ struct NewsWidgetProvider: TimelineProvider { } let cached = NewsWidgetService.cachedTopArticles() - let pick = cached.randomElement() + let pick = NewsWidgetEntryBuilder.currentArticle(from: cached) completion(NewsWidgetEntry( date: Date(), article: pick, @@ -84,7 +93,7 @@ struct NewsWidgetProvider: TimelineProvider { let entry: NewsWidgetEntry do { let fresh = try await NewsWidgetService.fetchFreshTopArticles() - if let pick = fresh.randomElement() { + if let pick = NewsWidgetEntryBuilder.currentArticle(from: fresh) { entry = NewsWidgetEntry( date: Date(), article: pick, @@ -97,7 +106,7 @@ struct NewsWidgetProvider: TimelineProvider { } } catch { let cached = NewsWidgetService.cachedTopArticles() - if let pick = cached.randomElement() { + if let pick = NewsWidgetEntryBuilder.currentArticle(from: cached) { entry = NewsWidgetEntry( date: Date(), article: pick, @@ -110,8 +119,7 @@ struct NewsWidgetProvider: TimelineProvider { } } - let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) - ?? Date().addingTimeInterval(15 * 60) + let nextRefresh = Date().addingTimeInterval(NewsWidgetEntryBuilder.refreshInterval) completion(Timeline(entries: [entry], policy: .after(nextRefresh))) } } From 40b0ad7e74bd5f3fbaeb27372a3b8a23245aab5a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 08:02:29 -0300 Subject: [PATCH 37/60] chore: remove schedule file --- .claude/scheduled_tasks.lock | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index ae98bc612..000000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"53eebf98-950c-4963-804d-ef8de7c14fe4","pid":5145,"procStart":"Wed May 6 16:43:05 2026","acquiredAt":1778087089693} \ No newline at end of file From 2e3f81cb8ff70a55bf435d4da7fb1df8ff41deb5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 08:05:50 -0300 Subject: [PATCH 38/60] fix: replace onAppear with task --- Bitkit/Components/Widgets/NewsWidget.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/Components/Widgets/NewsWidget.swift b/Bitkit/Components/Widgets/NewsWidget.swift index 35295183b..b111e1492 100644 --- a/Bitkit/Components/Widgets/NewsWidget.swift +++ b/Bitkit/Components/Widgets/NewsWidget.swift @@ -1,6 +1,6 @@ import SwiftUI -/// A widget that displays a news article (Figma v61). +/// A widget that displays a news article. struct NewsWidget: View { var options: NewsWidgetOptions = .init() var isEditing: Bool = false @@ -32,7 +32,7 @@ struct NewsWidget: View { } } } - .onAppear { + .task { viewModel.startUpdates() } } From a25a2215d7f6d5fa9e012589462017083cbe267c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 08:57:55 -0300 Subject: [PATCH 39/60] fix: use stable dafe format identifier --- Bitkit/Services/Widgets/NewsService.swift | 2 +- BitkitWidget/NewsHomeScreenWidget.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/Widgets/NewsService.swift b/Bitkit/Services/Widgets/NewsService.swift index bc1b1db38..01ee7a650 100644 --- a/Bitkit/Services/Widgets/NewsService.swift +++ b/Bitkit/Services/Widgets/NewsService.swift @@ -46,7 +46,7 @@ class NewsService { /// - Returns: Human-readable time difference (e.g. "5 hours ago") func timeAgo(from dateString: String) -> String { let formatter = DateFormatter() - formatter.locale = Locale.current + formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" guard let date = formatter.date(from: dateString) else { diff --git a/BitkitWidget/NewsHomeScreenWidget.swift b/BitkitWidget/NewsHomeScreenWidget.swift index ac787a524..4f8887ad5 100644 --- a/BitkitWidget/NewsHomeScreenWidget.swift +++ b/BitkitWidget/NewsHomeScreenWidget.swift @@ -19,7 +19,7 @@ private enum NewsWidgetEntryBuilder { static func relativeTime(from dateString: String) -> String { let formatter = DateFormatter() - formatter.locale = Locale.current + formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" guard let date = formatter.date(from: dateString) else { return "" } From c2e2bd3d6b827118ce9be5363c97c7577f6c163a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 10:33:41 -0300 Subject: [PATCH 40/60] feat: migrate blocks to v61 and implement OS widget --- Bitkit.xcodeproj/project.pbxproj | 4 + Bitkit/Components/Widgets/BlocksWidget.swift | 184 +++++++------ Bitkit/MainNavView.swift | 2 + Bitkit/Models/BlocksWidgetData.swift | 43 +++ Bitkit/Models/BlocksWidgetFields.swift | 85 ++++++ Bitkit/Models/BlocksWidgetOptions.swift | 55 ++++ .../Localization/en.lproj/Localizable.strings | 4 + .../BlocksHomeScreenWidgetOptionsStore.swift | 36 +++ Bitkit/Services/Widgets/BlocksService.swift | 147 ++++------- Bitkit/Utilities/WidgetsBackupConverter.swift | 6 +- .../ViewModels/Widgets/BlocksViewModel.swift | 2 +- Bitkit/ViewModels/WidgetsViewModel.swift | 13 + .../Widgets/BlocksWidgetPreviewView.swift | 247 ++++++++++++++++++ Bitkit/Views/Widgets/WidgetEditLogic.swift | 20 +- Bitkit/Views/Widgets/WidgetEditModels.swift | 234 +++-------------- Bitkit/Views/Widgets/WidgetEditView.swift | 14 +- .../calendar.imageset/Contents.json | 15 ++ .../calendar.imageset/calendar.pdf | Bin 0 -> 4026 bytes .../clock.imageset/Contents.json | 15 ++ .../Assets.xcassets/clock.imageset/clock.pdf | Bin 0 -> 3922 bytes .../coins.imageset/Contents.json | 15 ++ .../Assets.xcassets/coins.imageset/coins.pdf | Bin 0 -> 11668 bytes .../cube.imageset/Contents.json | 15 ++ .../Assets.xcassets/cube.imageset/cube.pdf | Bin 0 -> 6277 bytes .../file-text.imageset/Contents.json | 15 ++ .../file-text.imageset/file-text.pdf | Bin 0 -> 5903 bytes .../globe.imageset/Contents.json | 15 ++ .../Assets.xcassets/globe.imageset/globe.pdf | Bin 0 -> 4496 bytes .../transfer.imageset/Contents.json | 15 ++ .../transfer.imageset/transfer.pdf | Bin 0 -> 3957 bytes BitkitWidget/BitkitWidget.swift | 1 + BitkitWidget/BlocksHomeScreenWidget.swift | 216 +++++++++++++++ BitkitWidget/BlocksWidgetService.swift | 106 ++++++++ changelog.d/next/blocks-widget-v61.added.md | 1 + 34 files changed, 1114 insertions(+), 411 deletions(-) create mode 100644 Bitkit/Models/BlocksWidgetData.swift create mode 100644 Bitkit/Models/BlocksWidgetFields.swift create mode 100644 Bitkit/Models/BlocksWidgetOptions.swift create mode 100644 Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift create mode 100644 Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift create mode 100644 BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/calendar.imageset/calendar.pdf create mode 100644 BitkitWidget/Assets.xcassets/clock.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/clock.imageset/clock.pdf create mode 100644 BitkitWidget/Assets.xcassets/coins.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/coins.imageset/coins.pdf create mode 100644 BitkitWidget/Assets.xcassets/cube.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/cube.imageset/cube.pdf create mode 100644 BitkitWidget/Assets.xcassets/file-text.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/file-text.imageset/file-text.pdf create mode 100644 BitkitWidget/Assets.xcassets/globe.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/globe.imageset/globe.pdf create mode 100644 BitkitWidget/Assets.xcassets/transfer.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/transfer.imageset/transfer.pdf create mode 100644 BitkitWidget/BlocksHomeScreenWidget.swift create mode 100644 BitkitWidget/BlocksWidgetService.swift create mode 100644 changelog.d/next/blocks-widget-v61.added.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 15e22deb6..6c3746859 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -173,10 +173,14 @@ Fonts/InterTight-Regular.ttf, Constants/WidgetEnv.swift, Fonts/InterTight-SemiBold.ttf, + Models/BlocksWidgetData.swift, + Models/BlocksWidgetFields.swift, + Models/BlocksWidgetOptions.swift, Models/NewsWidgetData.swift, Models/NewsWidgetOptions.swift, Models/PriceWidgetData.swift, Models/PriceWidgetOptions.swift, + Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift, Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift, Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift, Styles/Colors.swift, diff --git a/Bitkit/Components/Widgets/BlocksWidget.swift b/Bitkit/Components/Widgets/BlocksWidget.swift index 5b6cda4bf..94b29626c 100644 --- a/Bitkit/Components/Widgets/BlocksWidget.swift +++ b/Bitkit/Components/Widgets/BlocksWidget.swift @@ -1,34 +1,28 @@ 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 +// MARK: - In-app label override + +/// In-app screens use the localized `widgets__widget__source` value for the Source field; +/// the OS widget uses the hardcoded English `BlocksWidgetField.label` since the widget +/// extension target does not have access to `LocalizeHelpers`. +extension BlocksWidgetField { + var inAppLabel: String { + if self == .showSource { return t("widgets__widget__source") } + return label + } } -/// A widget that displays Bitcoin block information +// MARK: - Widget + +/// In-app Bitcoin Blocks widget (v61). Renders the wide layout — used inside the home feed +/// and the wide carousel page on the preview screen. struct BlocksWidget: View { - /// Configuration options for the widget var options: BlocksWidgetOptions = .init() - - /// Flag indicating if the widget is in editing mode var isEditing: Bool = false - - /// Callback to signal when editing should end var onEditingEnd: (() -> Void)? - /// View model for handling block data @StateObject private var viewModel = BlocksViewModel.shared - /// Initialize the widget init( options: BlocksWidgetOptions = BlocksWidgetOptions(), isEditing: Bool = false, @@ -39,96 +33,96 @@ 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, isEditing: isEditing, onEditingEnd: onEditingEnd ) { - VStack(spacing: 0) { - if viewModel.isLoading { - WidgetContentBuilder.loadingView() - } else if viewModel.error != nil { - WidgetContentBuilder.errorView(t("widgets__blocks__error")) - } else if let data = viewModel.blockData { - VStack(spacing: 0) { - // Display block data rows based on options - ForEach(getDisplayableData(data), id: \.key) { item in - HStack(spacing: 0) { - HStack { - BodySSBText(item.label, textColor: .textSecondary) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) - - HStack { - BodyMSBText(item.value) - .lineLimit(1) - .truncationMode(.middle) - } - .frame(maxWidth: .infinity, alignment: .trailing) - } - .frame(minHeight: 28) - } - - if options.showSource { - WidgetContentBuilder.sourceRow(source: "mempool.space") - } - } - } - } + content } - .onAppear { + .task { 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)) + @ViewBuilder + private var content: some View { + if viewModel.isLoading && viewModel.blockData == nil { + WidgetContentBuilder.loadingView() + } else if viewModel.error != nil && viewModel.blockData == nil { + WidgetContentBuilder.errorView(t("widgets__blocks__error")) + } else if let data = viewModel.blockData { + BlocksWidgetWideContent(data: data, options: options) } - 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)) + } +} + +// MARK: - Wide layout (in-app + 343-wide carousel page + .systemMedium / .systemLarge OS widget) + +struct BlocksWidgetWideContent: View { + let data: CachedBlock + let options: BlocksWidgetOptions + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(options.enabledFields, id: \.self) { field in + BlocksWidgetWideRow(field: field, value: field.value(from: data)) + } } - if options.merkleRoot { - items.append((key: "merkleRoot", label: blocksMapping["merkleRoot"]!, value: data.merkleRoot)) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct BlocksWidgetWideRow: View { + let field: BlocksWidgetField + let value: String + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image(field.iconName) + .resizable() + .renderingMode(.template) + .foregroundColor(.brandAccent) + .frame(width: 20, height: 20) + + BodyMText(field.inAppLabel, textColor: .white80) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + BodyMSBText(value) + .lineLimit(1) + .truncationMode(.middle) } + } +} + +// MARK: - Compact layout (small carousel preview + 163×192 OS small widget) - return items +struct BlocksWidgetCompactContent: View { + let data: CachedBlock + let options: BlocksWidgetOptions + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(options.compactFields, id: \.self) { field in + HStack(alignment: .center, spacing: 8) { + Image(field.iconName) + .resizable() + .renderingMode(.template) + .foregroundColor(.brandAccent) + .frame(width: 20, height: 20) + + BodySSBText(field.value(from: data)) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.gray6) + .cornerRadius(16) } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index ba6c2e3f4..136ee5ff1 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -443,6 +443,8 @@ struct MainNavView: View { PriceWidgetPreviewView() case .news: NewsWidgetPreviewView() + case .blocks: + BlocksWidgetPreviewView() default: WidgetDetailView(id: widgetType) } diff --git a/Bitkit/Models/BlocksWidgetData.swift b/Bitkit/Models/BlocksWidgetData.swift new file mode 100644 index 000000000..f3f9c0d08 --- /dev/null +++ b/Bitkit/Models/BlocksWidgetData.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Persistable representation of the latest mined block, shared between the main app and the +/// widget extension via the App Group. Strings are pre-formatted by the main-app `BlocksService` +/// so the widget extension can render without re-running locale-sensitive formatting. +struct CachedBlock: Codable, Equatable { + let height: String + let time: String + let date: String + let transactionCount: String + let size: String + let fees: String +} + +/// Cache reader/writer used by both the main app and the widget extension. +enum BlocksWidgetCache { + static let appGroupSuiteName = "group.bitkit" + private static let latestKey = "blocks_widget_latest_v1" + private static let legacyStandardKey = "blocks_widget_cache" + + private static func defaults() -> UserDefaults { + UserDefaults(suiteName: appGroupSuiteName) ?? .standard + } + + static func saveLatest(_ block: CachedBlock) { + guard let encoded = try? JSONEncoder().encode(block) else { return } + defaults().set(encoded, forKey: latestKey) + } + + static func loadLatest() -> CachedBlock? { + guard let data = defaults().data(forKey: latestKey), + let decoded = try? JSONDecoder().decode(CachedBlock.self, from: data) + else { + return nil + } + return decoded + } + + /// One-time cleanup of the pre-App-Group cache that lived in `UserDefaults.standard`. + static func legacyDropStandardSuiteCache() { + UserDefaults.standard.removeObject(forKey: legacyStandardKey) + } +} diff --git a/Bitkit/Models/BlocksWidgetFields.swift b/Bitkit/Models/BlocksWidgetFields.swift new file mode 100644 index 000000000..31272ccfd --- /dev/null +++ b/Bitkit/Models/BlocksWidgetFields.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Ordered field set used by the v61 Blocks widget. Default-selected fields come first so +/// the compact (`.systemSmall`) layout can prioritize them when the row cap kicks in. +/// +/// Shared between the main app and the WidgetKit extension via the App Group target membership. +/// Labels are intentionally hardcoded English to avoid reaching into the main app's +/// `LocalizeHelpers` from the widget extension. +enum BlocksWidgetField: String, CaseIterable { + case height + case time + case date + case transactionCount + case size + case fees + case showSource + + /// The four fields enabled by default. The compact layout always renders these first when + /// present, then fills any remaining capacity with non-default fields. + static let defaults: [BlocksWidgetField] = [.height, .time, .date, .transactionCount] + static let extras: [BlocksWidgetField] = [.size, .fees, .showSource] + + var label: String { + switch self { + case .height: return "Block" + case .time: return "Time" + case .date: return "Date" + case .transactionCount: return "Transactions" + case .size: return "Size" + case .fees: return "Fees" + case .showSource: return "Source" + } + } + + /// Asset name for the brand-orange icon used in both the wide and compact layouts. + var iconName: String { + switch self { + case .height: return "cube" + case .time: return "clock" + case .date: return "calendar" + case .transactionCount: return "transfer" + case .size: return "file-text" + case .fees: return "coins" + case .showSource: return "globe" + } + } + + func isEnabled(in options: BlocksWidgetOptions) -> Bool { + switch self { + case .height: return options.height + case .time: return options.time + case .date: return options.date + case .transactionCount: return options.transactionCount + case .size: return options.size + case .fees: return options.fees + case .showSource: return options.showSource + } + } + + func value(from data: CachedBlock) -> String { + switch self { + case .height: return data.height + case .time: return data.time + case .date: return data.date + case .transactionCount: return data.transactionCount + case .size: return data.size + case .fees: return data.fees + case .showSource: return "mempool.space" + } + } +} + +extension BlocksWidgetOptions { + /// All enabled fields in declared order. Used by the wide / large layouts. + var enabledFields: [BlocksWidgetField] { + BlocksWidgetField.allCases.filter { $0.isEnabled(in: self) } + } + + /// Compact layout caps at 4 fields. Defaults come first, extras fill any remaining slots. + var compactFields: [BlocksWidgetField] { + let defaults = BlocksWidgetField.defaults.filter { $0.isEnabled(in: self) } + let extras = BlocksWidgetField.extras.filter { $0.isEnabled(in: self) } + return Array((defaults + extras).prefix(4)) + } +} diff --git a/Bitkit/Models/BlocksWidgetOptions.swift b/Bitkit/Models/BlocksWidgetOptions.swift new file mode 100644 index 000000000..1054be97e --- /dev/null +++ b/Bitkit/Models/BlocksWidgetOptions.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Options for configuring the in-app and home-screen Bitcoin Blocks widgets (shared via App Group). +/// +/// v61 reduces the field set to seven (Block / Time / Date / Transactions / Size / Fees / Source). +/// The custom decoder silently drops legacy keys (`weight`, `difficulty`, `hash`, `merkleRoot`) and +/// fills in defaults for any keys missing from older persisted blobs. +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 fees: Bool = false + var showSource: Bool = false + + init( + height: Bool = true, + time: Bool = true, + date: Bool = true, + transactionCount: Bool = false, + size: Bool = false, + fees: Bool = false, + showSource: Bool = false + ) { + self.height = height + self.time = time + self.date = date + self.transactionCount = transactionCount + self.size = size + self.fees = fees + self.showSource = showSource + } + + private enum CodingKeys: String, CodingKey { + case height + case time + case date + case transactionCount + case size + case fees + case showSource + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + height = try container.decodeIfPresent(Bool.self, forKey: .height) ?? true + time = try container.decodeIfPresent(Bool.self, forKey: .time) ?? true + date = try container.decodeIfPresent(Bool.self, forKey: .date) ?? true + transactionCount = try container.decodeIfPresent(Bool.self, forKey: .transactionCount) ?? false + size = try container.decodeIfPresent(Bool.self, forKey: .size) ?? false + fees = try container.decodeIfPresent(Bool.self, forKey: .fees) ?? false + showSource = try container.decodeIfPresent(Bool.self, forKey: .showSource) ?? false + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index c32f9de47..0c3c528fe 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1411,6 +1411,10 @@ "widgets__blocks__name" = "Bitcoin Blocks"; "widgets__blocks__description" = "Examine various statistics on newly mined Bitcoin Blocks."; "widgets__blocks__error" = "Couldn\'t get blocks data"; +"widgets__blocks__widget_settings" = "Widget Settings"; +"widgets__blocks__size_small" = "Small"; +"widgets__blocks__size_wide" = "Wide"; +"widgets__blocks__data_header" = "Data"; "widgets__facts__name" = "Bitcoin Facts"; "widgets__facts__description" = "Discover fun facts about Bitcoin, every time you open your wallet."; "widgets__calculator__name" = "Bitcoin Calculator"; diff --git a/Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift new file mode 100644 index 000000000..7b23a1f23 --- /dev/null +++ b/Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift @@ -0,0 +1,36 @@ +import Foundation +import WidgetKit + +/// Mirrors in-app Blocks widget options into the App Group so the WidgetKit extension can read them, +/// and centralizes the WidgetKit reload trigger for the Blocks home-screen widget. +enum BlocksHomeScreenWidgetOptionsStore { + /// WidgetKit `kind` for the home-screen Blocks widget (must match `BitkitBlocksWidget`). + static let blocksHomeScreenWidgetKind = "BitkitBlocksWidget" + + 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 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: blocksHomeScreenWidgetKind) + } +} diff --git a/Bitkit/Services/Widgets/BlocksService.swift b/Bitkit/Services/Widgets/BlocksService.swift index 4f00895c3..6e5df6d80 100644 --- a/Bitkit/Services/Widgets/BlocksService.swift +++ b/Bitkit/Services/Widgets/BlocksService.swift @@ -1,129 +1,94 @@ import Foundation -/// Service for fetching and caching Bitcoin block data +/// Service for fetching and caching the latest mined Bitcoin block. +/// +/// Writes the result to the App Group cache (`BlocksWidgetCache`) so the WidgetKit extension +/// can surface the same data, and triggers a timeline reload on the home-screen widget after +/// a successful fresh fetch. class BlocksService { static let shared = BlocksService() - 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 - private init() {} + private init() { + BlocksWidgetCache.legacyDropStandardSuiteCache() + } - /// Fetches the latest block data using stale-while-revalidate strategy - /// - Parameter returnCachedImmediately: If true, returns cached data immediately if available - /// - Returns: Block data - /// - Throws: URLError or decoding error + /// Fetches the latest block data using stale-while-revalidate strategy. + /// - Parameter returnCachedImmediately: If true, returns cached data immediately if available. @discardableResult - func fetchBlockData(returnCachedImmediately: Bool = true) async throws -> BlockData { - // If we want cached data and it exists, return it immediately + func fetchBlockData(returnCachedImmediately: Bool = true) async throws -> CachedBlock { if returnCachedImmediately, let cachedData = getCachedData() { - // Start fresh fetch in background to update cache (don't await) + // Background refresh; cache is updated automatically inside fetchFreshData. Task { do { try await fetchFreshData() - // Cache will be updated automatically in fetchFreshData } catch { - // Silent failure for background updates print("Background blocks data update failed: \(error)") } } return cachedData } - // No cache available or cache not requested - fetch fresh data return try await fetchFreshData() } - /// Fetches fresh data from API (always hits the network) + /// Fetches fresh data from the mempool API. @discardableResult - private func fetchFreshData() async throws -> BlockData { - // First get the tip hash + private func fetchFreshData() async throws -> CachedBlock { guard let tipUrl = URL(string: "\(baseUrl)/blocks/tip/hash") else { throw URLError(.badURL) } let (hashData, hashResponse) = try await URLSession.shared.data(from: tipUrl) - // Validate HTTP response - guard let httpResponse = hashResponse as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - guard httpResponse.statusCode == 200 else { + guard let httpResponse = hashResponse as? HTTPURLResponse, + httpResponse.statusCode == 200 + else { throw URLError(.badServerResponse) } let hash = String(data: hashData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - // Now get the block info - guard let blockUrl = URL(string: "\(baseUrl)/block/\(hash)") else { + // The v1 endpoint returns the same fields as the legacy one plus an `extras` block with `totalFees`. + guard let blockUrl = URL(string: "\(baseUrl)/v1/block/\(hash)") else { throw URLError(.badURL) } let (blockData, blockResponse) = try await URLSession.shared.data(from: blockUrl) - // Validate HTTP response - guard let httpBlockResponse = blockResponse as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - guard httpBlockResponse.statusCode == 200 else { + guard let httpBlockResponse = blockResponse as? HTTPURLResponse, + httpBlockResponse.statusCode == 200 + else { throw URLError(.badServerResponse) } - do { - let decoder = JSONDecoder() - let blockInfo = try decoder.decode(BlockInfo.self, from: blockData) - let formattedData = formatBlockInfo(blockInfo) + let blockInfo = try JSONDecoder().decode(BlockInfo.self, from: blockData) + let formattedData = formatBlockInfo(blockInfo) - // Cache the data - cacheData(formattedData) + cacheData(formattedData) + BlocksHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() - return formattedData - } catch { - throw error - } + return formattedData } - /// 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) - cache.set(encoded, forKey: cacheKey) - } catch { - // Handle silently - } + /// Caches block data to the App Group so the WidgetKit extension can read it. + func cacheData(_ data: CachedBlock) { + BlocksWidgetCache.saveLatest(data) } - /// Retrieves cached block data - /// - Returns: Block data if available - func getCachedData() -> BlockData? { - guard let data = cache.data(forKey: cacheKey) else { - return nil - } - - do { - let decoder = JSONDecoder() - return try decoder.decode(BlockData.self, from: data) - } catch { - return nil - } + /// Retrieves cached block data from the App Group. + func getCachedData() -> CachedBlock? { + BlocksWidgetCache.loadLatest() } - /// Formats raw block info into display-friendly format - /// - Parameter blockInfo: Raw block info from API - /// - Returns: Formatted block data - private func formatBlockInfo(_ blockInfo: BlockInfo) -> BlockData { + /// Formats raw block info into display-friendly format. + private func formatBlockInfo(_ blockInfo: BlockInfo) -> CachedBlock { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.locale = Locale.current - let difficulty = (blockInfo.difficulty / 1_000_000_000_000).formatted(.number.precision(.fractionLength(2))) - let size = Double(blockInfo.size) / 1024 - let weight = Double(blockInfo.weight) / 1024 / 1024 + let sizeKb = Double(blockInfo.size) / 1024 let timeFormatter = DateFormatter() timeFormatter.dateStyle = .none @@ -138,25 +103,24 @@ class BlocksService { let dateString = dateFormatter.string(from: date) let formattedHeight = formatter.string(from: NSNumber(value: blockInfo.height)) ?? "\(blockInfo.height)" - let formattedSize = "\(formatter.string(from: NSNumber(value: Int(size))) ?? "\(Int(size))") KB" + let formattedSize = "\(formatter.string(from: NSNumber(value: Int(sizeKb))) ?? "\(Int(sizeKb))") KB" let formattedTransactions = formatter.string(from: NSNumber(value: blockInfo.txCount)) ?? "\(blockInfo.txCount)" - let formattedWeight = "\(formatter.string(from: NSNumber(value: weight)) ?? "\(weight)") MWU" - return BlockData( - hash: blockInfo.id, - difficulty: difficulty, - size: formattedSize, - weight: formattedWeight, + let totalFeesSats = blockInfo.extras?.totalFees ?? 0 + let formattedFees = formatter.string(from: NSNumber(value: totalFeesSats)) ?? "\(totalFeesSats)" + + return CachedBlock( height: formattedHeight, time: time, date: dateString, transactionCount: formattedTransactions, - merkleRoot: blockInfo.merkleRoot + size: formattedSize, + fees: formattedFees ) } } -/// Raw block info model from mempool.space API +/// Raw block info model from mempool.space API (`/api/v1/block/:hash`). struct BlockInfo: Codable { let id: String let height: Int @@ -164,8 +128,11 @@ struct BlockInfo: Codable { let txCount: Int let size: Int let weight: Int - let difficulty: Double - let merkleRoot: String + let extras: Extras? + + struct Extras: Codable { + let totalFees: Int? + } enum CodingKeys: String, CodingKey { case id @@ -174,20 +141,6 @@ struct BlockInfo: Codable { case txCount = "tx_count" case size case weight - case difficulty - case merkleRoot = "merkle_root" + case extras } } - -/// Formatted block data for display -struct BlockData: Codable { - let hash: String - let difficulty: String - let size: String - let weight: String - let height: String - let time: String - let date: String - let transactionCount: String - let merkleRoot: String -} diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 6d0e392c8..32fe891da 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -127,10 +127,7 @@ enum WidgetsBackupConverter { date: prefs["showDate"] as? Bool ?? true, transactionCount: prefs["showTransactions"] as? Bool ?? false, size: prefs["showSize"] as? Bool ?? false, - weight: false, - difficulty: false, - hash: false, - merkleRoot: false, + fees: prefs["showFees"] as? Bool ?? false, showSource: prefs["showSource"] as? Bool ?? false ) optionsData = try? JSONEncoder().encode(iosOptions) @@ -201,6 +198,7 @@ enum WidgetsBackupConverter { "showDate": defaults.date, "showTransactions": defaults.transactionCount, "showSize": defaults.size, + "showFees": defaults.fees, "showSource": defaults.showSource, ] } diff --git a/Bitkit/ViewModels/Widgets/BlocksViewModel.swift b/Bitkit/ViewModels/Widgets/BlocksViewModel.swift index 2bbf1b0ad..be29b597b 100644 --- a/Bitkit/ViewModels/Widgets/BlocksViewModel.swift +++ b/Bitkit/ViewModels/Widgets/BlocksViewModel.swift @@ -6,7 +6,7 @@ import SwiftUI class BlocksViewModel: ObservableObject { static let shared = BlocksViewModel() - @Published var blockData: BlockData? + @Published var blockData: CachedBlock? @Published var isLoading = false @Published var error: Error? diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 3a29f381e..f49604026 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -240,6 +240,10 @@ class WidgetsViewModel: ObservableObject { return newsOptions } + if type == .blocks, let blocksOptions = BlocksHomeScreenWidgetOptionsStore.load() as? T { + return blocksOptions + } + // Return default options if none saved return getDefaultOptions(for: type) as! T } @@ -311,6 +315,7 @@ class WidgetsViewModel: ObservableObject { } syncPriceOptionsToHomeScreenWidget() syncNewsOptionsToHomeScreenWidget() + syncBlocksOptionsToHomeScreenWidget() } private func persistSavedWidgets() { @@ -322,6 +327,7 @@ class WidgetsViewModel: ObservableObject { } syncPriceOptionsToHomeScreenWidget() syncNewsOptionsToHomeScreenWidget() + syncBlocksOptionsToHomeScreenWidget() } /// Keeps the home-screen WidgetKit price widget in sync with in-app price widget options (App Group). @@ -337,4 +343,11 @@ class WidgetsViewModel: ObservableObject { NewsHomeScreenWidgetOptionsStore.save(options) NewsHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } + + /// Keeps the 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/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift b/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift new file mode 100644 index 000000000..f6805fdf4 --- /dev/null +++ b/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift @@ -0,0 +1,247 @@ +import SwiftUI + +/// Preview screen for the Bitcoin Blocks widget. +struct BlocksWidgetPreviewView: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + + @StateObject private var viewModel = BlocksViewModel.shared + + @State private var carouselPage: Int = 0 + @State private var showDeleteAlert = false + + private let widgetType: WidgetType = .blocks + + private var widgetName: String { + t("widgets__blocks__name") + } + + private var widgetDescription: String { + t("widgets__blocks__description") + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(widgetType) + } + + private var hasCustomOptions: Bool { + widgets.hasCustomOptions(for: widgetType) + } + + private var currentOptions: BlocksWidgetOptions { + widgets.getOptions(for: widgetType, as: BlocksWidgetOptions.self) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + NavigationBar(title: widgetName, showMenuButton: false, showGradient: false) + + VStack(alignment: .leading, spacing: 0) { + BodyMText(widgetDescription, textColor: .textSecondary) + .padding(.bottom, 16) + + Divider().background(Color.white.opacity(0.1)) + + widgetSettingsRow + + Divider().background(Color.white.opacity(0.1)) + } + + VStack(spacing: 16) { + Spacer(minLength: 0) + + carousel + + Spacer(minLength: 0) + + sizeLabel + + pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + buttonsRow + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray7.ignoresSafeArea()) + .task { + viewModel.startUpdates() + } + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteAlert, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } + Button(t("common__delete_yes"), role: .destructive) { onDelete() } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": widgetName])) + } + ) + } + + // MARK: - Widget Settings cell + + private var widgetSettingsRow: some View { + Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { + HStack(alignment: .center, spacing: 0) { + BodyMText(t("widgets__blocks__widget_settings"), textColor: .textPrimary) + + Spacer() + + BodyMText( + hasCustomOptions + ? t("widgets__widget__edit_custom") + : t("widgets__widget__edit_default"), + textColor: .textSecondary + ) + + Image("chevron") + .resizable() + .foregroundColor(.textSecondary) + .frame(width: 24, height: 24) + .padding(.leading, 5) + } + .frame(maxWidth: .infinity, minHeight: 51) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("WidgetEdit") + } + + // MARK: - Carousel + + private var carousel: some View { + TabView(selection: $carouselPage) { + compactPage.tag(0) + widePage.tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(height: 320) + } + + private var compactPage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = viewModel.blockData { + BlocksWidgetCompactContent(data: data, options: currentOptions) + } else { + placeholderCompact + } + } + .frame(width: 163, height: 192) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private var widePage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = viewModel.blockData { + BlocksWidgetWideContent(data: data, options: currentOptions) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + placeholderWide + } + } + .frame(maxWidth: .infinity) + Spacer(minLength: 0) + } + } + + private var placeholderCompact: some View { + Color.gray6 + .cornerRadius(16) + .overlay(ProgressView()) + } + + private var placeholderWide: some View { + Color.gray6 + .cornerRadius(16) + .frame(height: 180) + .overlay(ProgressView()) + } + + // MARK: - Size label & page indicator + + private var sizeLabel: some View { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__blocks__size_small") + : t("widgets__blocks__size_wide"), + textColor: .textSecondary + ) + .textCase(.uppercase) + Spacer() + } + } + + private var pageIndicator: some View { + HStack(spacing: 8) { + Spacer() + ForEach(0 ..< 2, id: \.self) { index in + Circle() + .fill(carouselPage == index ? Color.brandAccent : Color.white.opacity(0.32)) + .frame(width: 8, height: 8) + } + Spacer() + } + } + + // MARK: - Buttons + + private var buttonsRow: some View { + HStack(spacing: 16) { + if isWidgetSaved { + CustomButton( + title: t("common__delete"), + variant: .secondary, + size: .large, + shouldExpand: true + ) { + showDeleteAlert = true + } + .accessibilityIdentifier("WidgetDelete") + } + + CustomButton( + title: t("widgets__widget__save_widget"), + variant: .primary, + size: .large, + shouldExpand: true, + action: onSave + ) + .accessibilityIdentifier("WidgetSave") + } + } + + // MARK: - Actions + + private func onSave() { + widgets.saveWidget(widgetType) + navigation.reset() + } + + private func onDelete() { + widgets.deleteWidget(widgetType) + navigation.reset() + } +} + +#Preview { + NavigationStack { + BlocksWidgetPreviewView() + .environmentObject(NavigationViewModel()) + .environmentObject(WidgetsViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index e6b806683..b15fd90cf 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -34,9 +34,13 @@ class WidgetEditLogic: ObservableObject { var hasEnabledOption: Bool { switch widgetType { case .blocks: - // Blocks widget has many options, check if any are enabled - return blocksOptions.height || blocksOptions.time || blocksOptions.date || blocksOptions.transactionCount || blocksOptions.size - || blocksOptions.weight || blocksOptions.difficulty || blocksOptions.hash || blocksOptions.merkleRoot || blocksOptions.showSource + return blocksOptions.height + || blocksOptions.time + || blocksOptions.date + || blocksOptions.transactionCount + || blocksOptions.size + || blocksOptions.fees + || blocksOptions.showSource case .news, .facts: // Static items (showTitle) are always enabled, so these widgets always have enabled options return true @@ -92,14 +96,8 @@ class WidgetEditLogic: ObservableObject { blocksOptions.transactionCount.toggle() case "size": blocksOptions.size.toggle() - case "weight": - blocksOptions.weight.toggle() - case "difficulty": - blocksOptions.difficulty.toggle() - case "hash": - blocksOptions.hash.toggle() - case "merkleRoot": - blocksOptions.merkleRoot.toggle() + case "fees": + blocksOptions.fees.toggle() case "showSource": blocksOptions.showSource.toggle() default: diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index b7a30e3e6..65e875dd7 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -68,205 +68,41 @@ enum WidgetEditItemFactory { ) -> [WidgetEditItem] { var items: [WidgetEditItem] = [] - if let data = blocksViewModel.blockData { - items.append( - WidgetEditItem( - key: "height", - type: .toggleItem, - title: "Block", - value: data.height, - isChecked: blocksOptions.height - ) - ) - - items.append( - WidgetEditItem( - key: "time", - type: .toggleItem, - title: "Time", - value: data.time, - isChecked: blocksOptions.time - ) - ) - - items.append( - WidgetEditItem( - key: "date", - type: .toggleItem, - title: "Date", - value: data.date, - isChecked: blocksOptions.date - ) - ) - - items.append( - WidgetEditItem( - key: "transactionCount", - type: .toggleItem, - title: "Transactions", - value: data.transactionCount, - isChecked: blocksOptions.transactionCount - ) - ) - - items.append( - WidgetEditItem( - key: "size", - type: .toggleItem, - title: "Size", - value: data.size, - isChecked: blocksOptions.size - ) - ) - - items.append( - WidgetEditItem( - key: "weight", - type: .toggleItem, - title: "Weight", - value: data.weight, - isChecked: blocksOptions.weight - ) - ) - - items.append( - WidgetEditItem( - key: "difficulty", - type: .toggleItem, - title: "Difficulty", - value: data.difficulty, - isChecked: blocksOptions.difficulty - ) - ) - - items.append( - WidgetEditItem( - key: "hash", - type: .toggleItem, - title: "Hash", - value: data.hash, - isChecked: blocksOptions.hash - ) - ) - - items.append( - WidgetEditItem( - key: "merkleRoot", - type: .toggleItem, - title: "Merkle Root", - value: data.merkleRoot, - isChecked: blocksOptions.merkleRoot - ) - ) - - items.append( - WidgetEditItem( - key: "showSource", - type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("mempool.space", textColor: .textSecondary)), - isChecked: blocksOptions.showSource - ) - ) - } else { - // Fallback when no data is available - items.append( - WidgetEditItem( - key: "height", - type: .toggleItem, - title: "Block", - value: "870,123", - isChecked: blocksOptions.height - ) - ) - - items.append( - WidgetEditItem( - key: "time", - type: .toggleItem, - title: "Time", - value: "2:45:30 PM", - isChecked: blocksOptions.time - ) - ) - - items.append( - WidgetEditItem( - key: "date", - type: .toggleItem, - title: "Date", - value: "Dec 15, 2024", - isChecked: blocksOptions.date - ) - ) - - items.append( - WidgetEditItem( - key: "transactionCount", - type: .toggleItem, - title: "Transactions", - value: "3,456", - isChecked: blocksOptions.transactionCount - ) - ) - - items.append( - WidgetEditItem( - key: "size", - type: .toggleItem, - title: "Size", - value: "1,234 KB", - isChecked: blocksOptions.size - ) - ) - - items.append( - WidgetEditItem( - key: "weight", - type: .toggleItem, - title: "Weight", - value: "3.45 MWU", - isChecked: blocksOptions.weight - ) - ) - - items.append( - WidgetEditItem( - key: "difficulty", - type: .toggleItem, - title: "Difficulty", - value: "102.45 T", - isChecked: blocksOptions.difficulty - ) - ) - - items.append( - WidgetEditItem( - key: "hash", - type: .toggleItem, - title: "Hash", - value: "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054", - isChecked: blocksOptions.hash - ) - ) - - items.append( - WidgetEditItem( - key: "merkleRoot", - type: .toggleItem, - title: "Merkle Root", - value: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", - isChecked: blocksOptions.merkleRoot - ) - ) - - items.append( - WidgetEditItem( - key: "showSource", - type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("mempool.space", textColor: .textSecondary)), - isChecked: blocksOptions.showSource + items.append(sectionHeaderItem(key: "blocks_data_header", title: t("widgets__blocks__data_header"))) + + let fallback: [BlocksWidgetField: String] = [ + .height: "870,123", + .time: "2:45:30 PM", + .date: "Dec 15, 2024", + .transactionCount: "3,456", + .size: "1,234 KB", + .fees: "25,059,357", + ] + + for field in BlocksWidgetField.allCases { + let value: String = { + if field == .showSource { return "mempool.space" } + if let data = blocksViewModel.blockData { return field.value(from: data) } + return fallback[field] ?? "" + }() + + let titleView = AnyView( + HStack(spacing: 8) { + Image(field.iconName) + .resizable() + .renderingMode(.template) + .foregroundColor(.brandAccent) + .frame(width: 20, height: 20) + BodySSBText(field.inAppLabel, textColor: .textSecondary) + } + ) + items.append( + WidgetEditItem( + key: field.rawValue, + type: .toggleItem, + titleView: titleView, + valueView: AnyView(BodySSBText(value, textColor: .textSecondary)), + isChecked: field.isEnabled(in: blocksOptions) ) ) } diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index 4c3e81a88..19e98cbb6 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -55,16 +55,22 @@ struct WidgetEditView: View { editLogic?.resetOptions() } + /// v61 widget configuration screens (Price, News, Blocks) use the widget name as the title + /// and skip the legacy description block. + private var usesV61Header: Bool { + id == .price || id == .blocks + } + var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar( - title: id == .price ? widget.name : t("widgets__widget__edit"), - showMenuButton: id != .price, - showGradient: id != .price + title: usesV61Header ? widget.name : t("widgets__widget__edit"), + showMenuButton: !usesV61Header, + showGradient: !usesV61Header ) .padding(.bottom, 16) - if id != .price { + if !usesV61Header { BodyMText( t("widgets__widget__edit_description", variables: ["name": widget.name]), textColor: .textSecondary diff --git a/BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json b/BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json new file mode 100644 index 000000000..cba1e6c79 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "calendar.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/calendar.imageset/calendar.pdf b/BitkitWidget/Assets.xcassets/calendar.imageset/calendar.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4beb7cb14838b528bffa86ea3a2b9cc1292b30ee GIT binary patch literal 4026 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 literal 0 HcmV?d00001 diff --git a/BitkitWidget/Assets.xcassets/clock.imageset/Contents.json b/BitkitWidget/Assets.xcassets/clock.imageset/Contents.json new file mode 100644 index 000000000..0397df2e7 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/clock.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "clock.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/clock.imageset/clock.pdf b/BitkitWidget/Assets.xcassets/clock.imageset/clock.pdf new file mode 100644 index 0000000000000000000000000000000000000000..bf69f0658c14e25b8944fa1e0f97af9e26797f85 GIT binary patch literal 3922 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 GY5xblm%ZEo literal 0 HcmV?d00001 diff --git a/BitkitWidget/Assets.xcassets/coins.imageset/Contents.json b/BitkitWidget/Assets.xcassets/coins.imageset/Contents.json new file mode 100644 index 000000000..7ca0a2a25 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/coins.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "coins.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/coins.imageset/coins.pdf b/BitkitWidget/Assets.xcassets/coins.imageset/coins.pdf new file mode 100644 index 0000000000000000000000000000000000000000..88f4fd8183a883cf86ceebeee4217e1d685d65c7 GIT binary patch literal 11668 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 D0#_a3 literal 0 HcmV?d00001 diff --git a/BitkitWidget/Assets.xcassets/globe.imageset/Contents.json b/BitkitWidget/Assets.xcassets/globe.imageset/Contents.json new file mode 100644 index 000000000..fbc26e51a --- /dev/null +++ b/BitkitWidget/Assets.xcassets/globe.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "globe.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/globe.imageset/globe.pdf b/BitkitWidget/Assets.xcassets/globe.imageset/globe.pdf new file mode 100644 index 0000000000000000000000000000000000000000..08a6eddc11869f6e3dd38116f8f0d15382a8048f GIT binary patch literal 4496 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 J&#N0~{0Cjz-l+fp literal 0 HcmV?d00001 diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift index ba9bbdd06..48d2778c4 100644 --- a/BitkitWidget/BitkitWidget.swift +++ b/BitkitWidget/BitkitWidget.swift @@ -6,5 +6,6 @@ struct BitkitWidgetBundle: WidgetBundle { var body: some Widget { BitkitPriceWidget() BitkitNewsWidget() + BitkitBlocksWidget() } } diff --git a/BitkitWidget/BlocksHomeScreenWidget.swift b/BitkitWidget/BlocksHomeScreenWidget.swift new file mode 100644 index 000000000..40af64ad5 --- /dev/null +++ b/BitkitWidget/BlocksHomeScreenWidget.swift @@ -0,0 +1,216 @@ +import SwiftUI +import WidgetKit + +// MARK: - Entry + +struct BlocksWidgetEntry: TimelineEntry { + let date: Date + let block: CachedBlock? + let options: BlocksWidgetOptions + /// True when no fresh data could be fetched and there is nothing in cache to fall back to. + let showsError: Bool +} + +// MARK: - Helpers + +private enum BlocksWidgetEntryBuilder { + static let refreshInterval: TimeInterval = 15 * 60 +} + +// MARK: - Timeline Provider + +struct BlocksWidgetProvider: TimelineProvider { + /// Stable mock for widget gallery / placeholder snapshots. + private static let mockBlock = CachedBlock( + height: "870,123", + time: "01:31:42 UTC", + date: "11/2/2024", + transactionCount: "2,175", + size: "1,606 KB", + fees: "25,059,357" + ) + + private static let mockEntry = BlocksWidgetEntry( + date: Date(), + block: mockBlock, + options: BlocksWidgetOptions(), + showsError: false + ) + + func placeholder(in _: Context) -> BlocksWidgetEntry { + Self.mockEntry + } + + func getSnapshot(in context: Context, completion: @escaping (BlocksWidgetEntry) -> Void) { + let options = BlocksHomeScreenWidgetOptionsStore.load() + + if context.isPreview { + completion(BlocksWidgetEntry( + date: Self.mockEntry.date, + block: Self.mockBlock, + options: options, + showsError: false + )) + return + } + + let cached = BlocksWidgetService.cachedLatest() + completion(BlocksWidgetEntry( + date: Date(), + block: cached, + options: options, + showsError: false + )) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let options = BlocksHomeScreenWidgetOptionsStore.load() + + Task { + let entry: BlocksWidgetEntry + do { + let fresh = try await BlocksWidgetService.fetchFreshLatest() + entry = BlocksWidgetEntry(date: Date(), block: fresh, options: options, showsError: false) + } catch { + if let cached = BlocksWidgetService.cachedLatest() { + entry = BlocksWidgetEntry(date: Date(), block: cached, options: options, showsError: false) + } else { + entry = BlocksWidgetEntry(date: Date(), block: nil, options: options, showsError: true) + } + } + + let nextRefresh = Date().addingTimeInterval(BlocksWidgetEntryBuilder.refreshInterval) + completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + } + } +} + +// MARK: - View + +struct BlocksHomeScreenWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode + + var entry: BlocksWidgetProvider.Entry + + var body: some View { + content + .containerBackground(for: .widget) { backgroundView } + } + + @ViewBuilder + private var content: some View { + if entry.showsError { + errorView + } else if let block = entry.block { + switch widgetFamily { + case .systemSmall: + compactLayout(block: block) + case .systemLarge: + wideLayout(block: block, fields: entry.options.enabledFields) + default: + wideLayout(block: block, fields: entry.options.compactFields) + } + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + + // MARK: - Layouts + + /// Compact (`.systemSmall`): icon + value rows, capped at 4. Default-selected fields + /// take priority; remaining slots are filled by extras in declared order. + private func compactLayout(block: CachedBlock) -> some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(entry.options.compactFields, id: \.self) { field in + HStack(alignment: .center, spacing: 8) { + iconImage(field: field) + Text(field.value(from: block)) + .font(Fonts.semiBold(size: 15)) + .foregroundColor(titleTextColor) + .lineLimit(1) + .truncationMode(.middle) + .widgetAccentable() + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + /// Wide layout: icon + label + value rows. `.systemMedium` is capped at 4 fields with the + /// same default-priority logic as the small widget; `.systemLarge` shows all enabled fields. + private func wideLayout(block: CachedBlock, fields: [BlocksWidgetField]) -> some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(fields, id: \.self) { field in + HStack(alignment: .center, spacing: 8) { + iconImage(field: field) + Text(field.label) + .font(Fonts.regular(size: 17)) + .foregroundColor(labelTextColor) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + Text(field.value(from: block)) + .font(Fonts.semiBold(size: 17)) + .foregroundColor(titleTextColor) + .lineLimit(1) + .truncationMode(.middle) + .widgetAccentable() + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func iconImage(field: BlocksWidgetField) -> some View { + Image(field.iconName) + .resizable() + .renderingMode(.template) + .foregroundColor(iconColor) + .frame(width: 20, height: 20) + .widgetAccentable() + } + + private var errorView: some View { + Text("Couldn’t load blocks data.") + .font(Fonts.regular(size: 13)) + .foregroundColor(labelTextColor) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Colors + + private var backgroundView: some View { + widgetRenderingMode == .fullColor ? Color.gray6 : Color.clear + } + + private var titleTextColor: Color { + widgetRenderingMode == .fullColor ? .white : .primary + } + + private var labelTextColor: Color { + widgetRenderingMode == .fullColor ? .white.opacity(0.8) : .secondary + } + + private var iconColor: Color { + widgetRenderingMode == .fullColor ? .brandAccent : .primary + } +} + +// MARK: - Widget Configuration + +struct BitkitBlocksWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration( + kind: BlocksHomeScreenWidgetOptionsStore.blocksHomeScreenWidgetKind, + provider: BlocksWidgetProvider() + ) { entry in + BlocksHomeScreenWidgetEntryView(entry: entry) + } + .configurationDisplayName("Bitcoin Blocks") + .description("Latest mined Bitcoin block, mirroring the in-app blocks widget.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} diff --git a/BitkitWidget/BlocksWidgetService.swift b/BitkitWidget/BlocksWidgetService.swift new file mode 100644 index 000000000..59b5a1dbe --- /dev/null +++ b/BitkitWidget/BlocksWidgetService.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Slim Bitcoin Blocks fetcher used inside the WidgetKit extension. +/// +/// Reads the latest `CachedBlock` from the App Group (written by the main app's `BlocksService`) +/// and falls back to a direct mempool.space fetch when the cache is empty. The cache itself is +/// owned by the main app; this service intentionally does not write back to it. +enum BlocksWidgetService { + enum FetchError: Error { + case invalidURL + case unexpectedResponse + case missingData + } + + private static let baseUrl = "https://mempool.space/api" + + static func cachedLatest() -> CachedBlock? { + BlocksWidgetCache.loadLatest() + } + + static func fetchFreshLatest() async throws -> CachedBlock { + guard let tipUrl = URL(string: "\(baseUrl)/blocks/tip/hash") else { + throw FetchError.invalidURL + } + + let (hashData, hashResponse) = try await URLSession.shared.data(from: tipUrl) + guard let httpResponse = hashResponse as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw FetchError.unexpectedResponse + } + + let hash = String(data: hashData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + guard let blockUrl = URL(string: "\(baseUrl)/v1/block/\(hash)") else { + throw FetchError.invalidURL + } + + let (blockData, blockResponse) = try await URLSession.shared.data(from: blockUrl) + guard let httpBlockResponse = blockResponse as? HTTPURLResponse, httpBlockResponse.statusCode == 200 else { + throw FetchError.unexpectedResponse + } + + let info = try JSONDecoder().decode(WireBlock.self, from: blockData) + return Self.format(info) + } + + private static func format(_ info: WireBlock) -> CachedBlock { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = Locale.current + + let sizeKb = Double(info.size) / 1024 + + let timeFormatter = DateFormatter() + timeFormatter.dateStyle = .none + timeFormatter.timeStyle = .medium + + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .short + dateFormatter.timeStyle = .none + + let date = Date(timeIntervalSince1970: TimeInterval(info.timestamp)) + + let formattedHeight = formatter.string(from: NSNumber(value: info.height)) ?? "\(info.height)" + let formattedSize = "\(formatter.string(from: NSNumber(value: Int(sizeKb))) ?? "\(Int(sizeKb))") KB" + let formattedTransactions = formatter.string(from: NSNumber(value: info.txCount)) ?? "\(info.txCount)" + let totalFees = info.extras?.totalFees ?? 0 + let formattedFees = formatter.string(from: NSNumber(value: totalFees)) ?? "\(totalFees)" + + return CachedBlock( + height: formattedHeight, + time: timeFormatter.string(from: date), + date: dateFormatter.string(from: date), + transactionCount: formattedTransactions, + size: formattedSize, + fees: formattedFees + ) + } +} + +// MARK: - Wire models + +/// Local mirror of the mempool `/api/v1/block/:hash` payload — kept private so the extension +/// stays small and decoupled from the main app's `BlockInfo`. +private struct WireBlock: Codable { + let id: String + let height: Int + let timestamp: Int + let txCount: Int + let size: Int + let weight: Int + let extras: WireExtras? + + enum CodingKeys: String, CodingKey { + case id + case height + case timestamp + case txCount = "tx_count" + case size + case weight + case extras + } +} + +private struct WireExtras: Codable { + let totalFees: Int? +} diff --git a/changelog.d/next/blocks-widget-v61.added.md b/changelog.d/next/blocks-widget-v61.added.md new file mode 100644 index 000000000..f4c8ddcf4 --- /dev/null +++ b/changelog.d/next/blocks-widget-v61.added.md @@ -0,0 +1 @@ +Bitcoin Blocks home-screen widget and v61 in-app redesign. From 2469d3b1bfe12b9a37fd28576d8875c747042e39 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 10:36:07 -0300 Subject: [PATCH 41/60] fix: use arrow-up-down for transfer icons --- Bitkit/Models/BlocksWidgetFields.swift | 2 +- .../Contents.json | 2 +- .../arrow-up-down.pdf} | Bin 3957 -> 4034 bytes 3 files changed, 2 insertions(+), 2 deletions(-) rename BitkitWidget/Assets.xcassets/{transfer.imageset => arrow-up-down.imageset}/Contents.json (82%) rename BitkitWidget/Assets.xcassets/{transfer.imageset/transfer.pdf => arrow-up-down.imageset/arrow-up-down.pdf} (83%) diff --git a/Bitkit/Models/BlocksWidgetFields.swift b/Bitkit/Models/BlocksWidgetFields.swift index 31272ccfd..8c09b0465 100644 --- a/Bitkit/Models/BlocksWidgetFields.swift +++ b/Bitkit/Models/BlocksWidgetFields.swift @@ -38,7 +38,7 @@ enum BlocksWidgetField: String, CaseIterable { case .height: return "cube" case .time: return "clock" case .date: return "calendar" - case .transactionCount: return "transfer" + case .transactionCount: return "arrow-up-down" case .size: return "file-text" case .fees: return "coins" case .showSource: return "globe" diff --git a/BitkitWidget/Assets.xcassets/transfer.imageset/Contents.json b/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/Contents.json similarity index 82% rename from BitkitWidget/Assets.xcassets/transfer.imageset/Contents.json rename to BitkitWidget/Assets.xcassets/arrow-up-down.imageset/Contents.json index bf9fbdf44..ae0bb361f 100644 --- a/BitkitWidget/Assets.xcassets/transfer.imageset/Contents.json +++ b/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "transfer.pdf", + "filename" : "arrow-up-down.pdf", "idiom" : "universal" } ], diff --git a/BitkitWidget/Assets.xcassets/transfer.imageset/transfer.pdf b/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/arrow-up-down.pdf similarity index 83% rename from BitkitWidget/Assets.xcassets/transfer.imageset/transfer.pdf rename to BitkitWidget/Assets.xcassets/arrow-up-down.imageset/arrow-up-down.pdf index 667ed4009bb7f5966e79171ddf58f4b5d1a5ad91..34bc06e510810c1bf25816d90acad3497e127e1b 100644 GIT binary patch delta 646 zcmew=cSwGNEqDFeV9PueL!L8VyZXd8tZCkrv_D~^e@4Q_gKHQM|7VX`awF>;V+E6O zM(Fe-Li``U>gMIw-_f4-_j2uy7tJ@s_&#j9aBDHMO8f?fH~(Wl=!#0cnVFq2m%&ZK z_QJQF`;(*lkEtr#t#$Kivtcx2-+FP~3a5?Nw!JkA(6W8LCst>=Q@y7m^9xpOPLBk^ z27R6=i+M%*f1mI~c>QQ)d{O&`ohglJcg)0D3@*$5%~X@wz%b{^yMH_P@4vJA_um|O zF^PFjr(FEMDE{`Cx+3ppzfkZKvy}Oo+b&#;xR=8@d+QgIMX6OMm!>d$y&Y?7amQe1 zceY)?`;86ROL<<+PSog+Ii2>WUMSF|G}1fo$%da6u5%`>XiK$_IFk40Q{agwzd4o` z@Fcz}epc0Id9nXN2=CXO7x{zZrOf`89Zx-;_r%+@#yhj`Sg`iNiJa+LwHsyYBJVcO znZePW`#jA@_^9r>kp1sJH9uFiv*EcpflHz9?9T|@rTtaD($hWS|IadecC82K@p^uk z&+V2c7P9H6{yD_(#qJw3qj6(l^uYr&uCOQ=eLZjTuz~f#l6A}8>U_UceWSZ*!K>35 zTd$}`75?X`%X#pj>^=LsySy@rcb=;ATX#;ob0YC`;iDxgt4syX6c}{e{joOOg1yh8 z?RKO0p-ww{{!r)Iyf0^OthnFYs4V*Os3XD?xoq#aTf2i}Co}TOGMgG3PFCa%k|5a{-EqDFf$+meihCHp+bCX^%O#7<8>KkX1jHAPZq+Ep$e;aMGEJVclCgt9l zc~)Tl3HL53yZuKVKW(o*mcDQIeZ5~B-f&F+yky?It*0+8{`sou{mN+v4Yprcc|bBT zK;!uDn%jpoWAAAvtkn!OEwHv&66n6uxv}Wn(;0q>tB$Z)=S`a`;Z>iJ5y_RYd_u&w zlP<59ev$K?Qs{D0VCC^j27}8t-twJdRVaG(K3geqrsL(mMfUr5nt%U$vHXTg^_iW5 zwjTt}E}xk3WO4H{A@+LdDAO?=8|+JFyAxED(F3q3S(9StZ{}&ff2>sM)3NtYzntlXe^`DKP(i$zOi{qbzht1G<+%ZWpsQ)uV!m^F^xpVib3Eg;O@ormQfArUXzr;!^pFCb+xr}|@ zqsRFlwwA{8O5GiA0a|3iCQxju!Au~hs$r=2CoMz@`T&k+B{%!yuegSX* From 442ea604e9f1df4c223627cc01b18fc089e3762f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 10:59:48 -0300 Subject: [PATCH 42/60] fix: drop large OS widget support --- BitkitWidget/BlocksHomeScreenWidget.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/BitkitWidget/BlocksHomeScreenWidget.swift b/BitkitWidget/BlocksHomeScreenWidget.swift index 40af64ad5..17ab544da 100644 --- a/BitkitWidget/BlocksHomeScreenWidget.swift +++ b/BitkitWidget/BlocksHomeScreenWidget.swift @@ -106,8 +106,6 @@ struct BlocksHomeScreenWidgetEntryView: View { switch widgetFamily { case .systemSmall: compactLayout(block: block) - case .systemLarge: - wideLayout(block: block, fields: entry.options.enabledFields) default: wideLayout(block: block, fields: entry.options.compactFields) } @@ -139,8 +137,8 @@ struct BlocksHomeScreenWidgetEntryView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - /// Wide layout: icon + label + value rows. `.systemMedium` is capped at 4 fields with the - /// same default-priority logic as the small widget; `.systemLarge` shows all enabled fields. + /// Wide layout (`.systemMedium`): icon + label + value rows, capped at 4 fields with the + /// same default-priority logic as the small widget. private func wideLayout(block: CachedBlock, fields: [BlocksWidgetField]) -> some View { VStack(alignment: .leading, spacing: 12) { ForEach(fields, id: \.self) { field in @@ -211,6 +209,6 @@ struct BitkitBlocksWidget: Widget { } .configurationDisplayName("Bitcoin Blocks") .description("Latest mined Bitcoin block, mirroring the in-app blocks widget.") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .supportedFamilies([.systemSmall, .systemMedium]) } } From 6e3ab90ddda2937ac9e9296d2d9a017632f10a5a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 13:51:43 -0300 Subject: [PATCH 43/60] fix: reuse existing text component and remove scale factor --- BitkitWidget/PriceHomeScreenWidget.swift | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index 55ab2b7c0..a01033047 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -120,12 +120,12 @@ struct PriceHomeScreenWidgetEntryView: View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 0) { - captionUpText(data.name) + CaptionMText(data.name, textColor: secondaryTextColor) Spacer(minLength: 0) - captionUpText(entry.options.selectedPeriod.rawValue) + CaptionMText(entry.options.selectedPeriod.rawValue, textColor: secondaryTextColor) } - priceText(data.price, size: 22, lineHeight: 26) + priceText(data.price, size: 22) Text(data.change.formatted) .font(Fonts.semiBold(size: 15)) @@ -147,7 +147,7 @@ struct PriceHomeScreenWidgetEntryView: View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 4) { HStack(alignment: .center, spacing: 16) { - captionUpText("\(data.name) \(entry.options.selectedPeriod.rawValue)") + CaptionMText("\(data.name) \(entry.options.selectedPeriod.rawValue)", textColor: secondaryTextColor) .frame(maxWidth: .infinity, alignment: .leading) Text(data.change.formatted) @@ -157,7 +157,7 @@ struct PriceHomeScreenWidgetEntryView: View { .widgetAccentable() } - priceText(data.price, size: 34, lineHeight: 34) + priceText(data.price, size: 34) } Spacer(minLength: 4) @@ -169,19 +169,11 @@ struct PriceHomeScreenWidgetEntryView: View { // MARK: - Sub-views - private func captionUpText(_ text: String) -> Text { - Text(text) - .font(Fonts.medium(size: 13)) - .tracking(1) - .foregroundColor(secondaryTextColor) - } - - private func priceText(_ value: String, size: CGFloat, lineHeight: CGFloat) -> some View { + private func priceText(_ value: String, size: CGFloat) -> some View { Text(value) .font(Fonts.bold(size: size)) .foregroundColor(valueTextColor) .lineLimit(1) - .minimumScaleFactor(0.7) .widgetAccentable() } From b2df71f314a2717f52ce5551ed299adb2e331ce8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 13:58:59 -0300 Subject: [PATCH 44/60] fix: display white32 checkmark for unselected item --- Bitkit/Views/Widgets/WidgetEditItemView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index c21c8ad01..65c6d6c9f 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -33,10 +33,10 @@ struct WidgetEditItemView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - if item.type != .staticItem, item.isChecked { + if item.type != .staticItem { Image("check-mark") .resizable() - .foregroundColor(.brandAccent) + .foregroundColor(item.isChecked ? .brandAccent : .white32) .frame(width: 32, height: 32) } } From 7994460cb14182925f53c476667a03d91ff2a216 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 14:08:25 -0300 Subject: [PATCH 45/60] fix: vertical padding anchored to checkbox image --- Bitkit/Views/Widgets/WidgetEditItemView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index 65c6d6c9f..ce47e12b3 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -21,7 +21,7 @@ struct WidgetEditItemView: View { } private var row: some View { - VStack(spacing: 0) { + VStack(spacing: 8) { HStack(spacing: 16) { item.titleView .frame(maxWidth: .infinity, alignment: .leading) @@ -40,10 +40,11 @@ struct WidgetEditItemView: View { .frame(width: 32, height: 32) } } - .padding(.vertical, 16) + .frame(minHeight: 32) .contentShape(Rectangle()) Divider() } + .padding(.top, 8) } } From 200d2f84f7ea3ab5694bbe0e1d00b3fcac76c66a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 14:18:42 -0300 Subject: [PATCH 46/60] fix: remove the gray bg and custom bg from Navigation bar --- Bitkit/Components/NavigationBar.swift | 19 +++++-------------- .../Widgets/PriceWidgetPreviewView.swift | 3 +-- Bitkit/Views/Widgets/WidgetEditView.swift | 4 +--- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/Bitkit/Components/NavigationBar.swift b/Bitkit/Components/NavigationBar.swift index 787334442..36fb13dbb 100644 --- a/Bitkit/Components/NavigationBar.swift +++ b/Bitkit/Components/NavigationBar.swift @@ -7,7 +7,6 @@ struct NavigationBar: View { let title: String let showBackButton: Bool let showMenuButton: Bool - let showGradient: Bool let action: AnyView? let icon: String? let onBack: (() -> Void)? @@ -16,7 +15,6 @@ struct NavigationBar: View { title: String, showBackButton: Bool = true, showMenuButton: Bool = true, - showGradient: Bool = true, action: AnyView? = nil, icon: String? = nil, onBack: (() -> Void)? = nil @@ -24,7 +22,6 @@ struct NavigationBar: View { self.title = title self.showBackButton = showBackButton self.showMenuButton = showMenuButton - self.showGradient = showGradient self.action = action self.icon = icon self.onBack = onBack @@ -92,17 +89,11 @@ struct NavigationBar: View { } } .frame(height: 48) - .background( - Group { - if showGradient { - LinearGradient( - colors: [.black, .black.opacity(0)], - startPoint: .top, - endPoint: .bottom - ) - } - } - ) + .background(LinearGradient( + colors: [.black, .black.opacity(0)], + startPoint: .top, + endPoint: .bottom + )) .zIndex(.infinity) } } diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index 54d7bca05..57fba9cb4 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -43,7 +43,7 @@ struct PriceWidgetPreviewView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - NavigationBar(title: widgetName, showMenuButton: false, showGradient: false) + NavigationBar(title: widgetName, showMenuButton: false) VStack(alignment: .leading, spacing: 0) { BodyMText(widgetDescription, textColor: .textSecondary) @@ -74,7 +74,6 @@ struct PriceWidgetPreviewView: View { .navigationBarHidden(true) .padding(.horizontal, 16) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray7.ignoresSafeArea()) .task { let options = currentOptions viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index 4c3e81a88..020bbebf7 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -59,8 +59,7 @@ struct WidgetEditView: View { VStack(alignment: .leading, spacing: 0) { NavigationBar( title: id == .price ? widget.name : t("widgets__widget__edit"), - showMenuButton: id != .price, - showGradient: id != .price + showMenuButton: id != .price ) .padding(.bottom, 16) @@ -114,7 +113,6 @@ struct WidgetEditView: View { .navigationBarHidden(true) .padding(.horizontal, 16) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray7.ignoresSafeArea()) .onAppear { if editLogic == nil { let logic = WidgetEditLogic(widgetType: id, widgetsViewModel: widgets) From 870d5e804c0cd411491f096df46ed43a2af0fdf4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 14:24:03 -0300 Subject: [PATCH 47/60] fix: try to fetch real data for preview --- BitkitWidget/PriceHomeScreenWidget.swift | 36 ++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index a01033047..f30057c11 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -34,23 +34,43 @@ struct PriceWidgetProvider: TimelineProvider { }() func placeholder(in _: Context) -> PriceWidgetEntry { - Self.mockEntry + let options = PriceHomeScreenWidgetOptionsStore.load() + if let cached = PriceWidgetService.cachedPrices(pairs: [options.selectedPair], period: options.selectedPeriod), + !cached.isEmpty + { + return PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: false) + } + return Self.mockEntry } func getSnapshot(in context: Context, completion: @escaping (PriceWidgetEntry) -> Void) { let options = PriceHomeScreenWidgetOptionsStore.load() + let cached = PriceWidgetService.cachedPrices(pairs: [options.selectedPair], period: options.selectedPeriod) ?? [] + + if !cached.isEmpty { + completion(PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: false)) + return + } if context.isPreview { - completion(PriceWidgetEntry( - date: Self.mockEntry.date, - prices: Self.mockEntry.prices, - options: options, - showsError: false - )) + Task { + if let fresh = try? await PriceWidgetService.fetchFreshPrices( + pairs: [options.selectedPair], + period: options.selectedPeriod + ), !fresh.isEmpty { + completion(PriceWidgetEntry(date: Date(), prices: fresh, options: options, showsError: false)) + } else { + completion(PriceWidgetEntry( + date: Self.mockEntry.date, + prices: Self.mockEntry.prices, + options: options, + showsError: false + )) + } + } return } - let cached = PriceWidgetService.cachedPrices(pairs: [options.selectedPair], period: options.selectedPeriod) ?? [] completion(PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: false)) } From d2f4120ea937b326e46bbf4756ae84d8ec1d0839 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 14:30:24 -0300 Subject: [PATCH 48/60] refactor: make string keys generic to be reused in the furue implementtions --- Bitkit/Resources/Localization/en.lproj/Localizable.strings | 6 +++--- Bitkit/Views/Widgets/PriceWidgetPreviewView.swift | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index afad97a20..4bcd88e86 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1397,9 +1397,9 @@ "widgets__price__period_week" = "Week"; "widgets__price__period_month" = "Month"; "widgets__price__period_year" = "Year"; -"widgets__price__size_small" = "Small"; -"widgets__price__size_wide" = "Wide"; -"widgets__price__widget_settings" = "Widget Settings"; +"widgets__widget__size_small" = "Small"; +"widgets__widget__size_wide" = "Wide"; +"widgets__widget__settings" = "Widget Settings"; "widgets__widget__save_widget" = "Save Widget"; "widgets__news__name" = "Bitcoin Headlines"; "widgets__news__description" = "Read the latest & greatest Bitcoin headlines from various news sites."; diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index 57fba9cb4..ff2ca7b5b 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -96,7 +96,7 @@ struct PriceWidgetPreviewView: View { private var widgetSettingsRow: some View { Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { HStack(alignment: .center, spacing: 0) { - BodyMText(t("widgets__price__widget_settings"), textColor: .textPrimary) + BodyMText(t("widgets__widget__settings"), textColor: .textPrimary) Spacer() @@ -188,8 +188,8 @@ struct PriceWidgetPreviewView: View { Spacer() CaptionMText( carouselPage == 0 - ? t("widgets__price__size_small") - : t("widgets__price__size_wide"), + ? t("widgets__widget__size_small") + : t("widgets__widget__size_wide"), textColor: .textSecondary ) .textCase(.uppercase) From 1c8350ad020146cb2d230e022a141ffabf2a869c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 14:38:39 -0300 Subject: [PATCH 49/60] fix: make prevew frame height adaptable --- Bitkit/Views/Widgets/PriceWidgetPreviewView.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index ff2ca7b5b..ef037893c 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -57,12 +57,8 @@ struct PriceWidgetPreviewView: View { } VStack(spacing: 16) { - Spacer(minLength: 0) - carousel - Spacer(minLength: 0) - sizeLabel pageIndicator @@ -74,6 +70,7 @@ struct PriceWidgetPreviewView: View { .navigationBarHidden(true) .padding(.horizontal, 16) .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() .task { let options = currentOptions viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) @@ -131,7 +128,7 @@ struct PriceWidgetPreviewView: View { .tag(1) } .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(height: 320) + .frame(maxHeight: .infinity) } private var compactPage: some View { From ddfd42b2ab621946e996dba2aa47f4cdab409d50 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 15:01:15 -0300 Subject: [PATCH 50/60] fix: display checkmark for title --- Bitkit/Views/Widgets/WidgetEditLogic.swift | 6 ++++-- Bitkit/Views/Widgets/WidgetEditModels.swift | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index e6b806683..2b603f768 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -37,8 +37,10 @@ class WidgetEditLogic: ObservableObject { // Blocks widget has many options, check if any are enabled return blocksOptions.height || blocksOptions.time || blocksOptions.date || blocksOptions.transactionCount || blocksOptions.size || blocksOptions.weight || blocksOptions.difficulty || blocksOptions.hash || blocksOptions.merkleRoot || blocksOptions.showSource - case .news, .facts: - // Static items (showTitle) are always enabled, so these widgets always have enabled options + case .news: + return newsOptions.showTitle || newsOptions.showSource || newsOptions.showDate + case .facts: + // Facts widget's static title is always shown, so it always has an enabled option return true case .weather: // Weather widget has multiple options, check if any are enabled diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index b7a30e3e6..114bbdf9a 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -313,10 +313,10 @@ enum WidgetEditItemFactory { items.append( WidgetEditItem( key: "showTitle", - type: .staticItem, + type: .toggleItem, titleView: AnyView(TitleText(data.title)), valueView: nil, - isChecked: true // Static items are always shown + isChecked: newsOptions.showTitle ) ) @@ -344,10 +344,10 @@ enum WidgetEditItemFactory { items.append( WidgetEditItem( key: "showTitle", - type: .staticItem, + type: .toggleItem, titleView: AnyView(TitleText("How Bitcoin changed El Salvador in more ways...")), valueView: nil, - isChecked: true // Static items are always shown + isChecked: newsOptions.showTitle ) ) From 88843b67b218d7bd6df6574ad64c3de3ea86daba Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 15:24:38 -0300 Subject: [PATCH 51/60] fix: remove app group fallback --- Bitkit/ViewModels/WidgetsViewModel.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index ee0edad19..8bd9a4d23 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -232,10 +232,6 @@ class WidgetsViewModel: ObservableObject { return options } - if type == .price, let priceOptions = PriceHomeScreenWidgetOptionsStore.load() as? T { - return priceOptions - } - // Return default options if none saved return getDefaultOptions(for: type) as! T } @@ -258,6 +254,10 @@ class WidgetsViewModel: ObservableObject { } persistSavedWidgets() + + if type == .price, let priceOptions = options as? PriceWidgetOptions { + syncPriceOptionsToHomeScreenWidget(priceOptions) + } } catch { print("Failed to save widget options: \(error)") } @@ -305,7 +305,6 @@ class WidgetsViewModel: ObservableObject { savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() } - syncPriceOptionsToHomeScreenWidget() } private func persistSavedWidgets() { @@ -315,12 +314,12 @@ 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) + /// Mirrors in-app price widget options to the App Group so the home-screen WidgetKit widget can read them. + /// Only invoked when the user explicitly changes price widget options — adding, deleting, or resetting + /// in-app widgets must not affect the independent OS home-screen widget. + private func syncPriceOptionsToHomeScreenWidget(_ options: PriceWidgetOptions) { PriceHomeScreenWidgetOptionsStore.save(options) PriceHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } From ab3fde3e19ccd302347c65224e360294d323c04e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Sena?= Date: Thu, 14 May 2026 12:13:57 -0400 Subject: [PATCH 52/60] feat: price widget v61 (#542) * feat: port price widgets related screens to figma V61 * fix: spacing and alignment * feat: hide menu button from nabigation bar * fix: padding * fix: remove systemLarge widget option * fix: collect results in input order instead of completion order * fix: pr comments * fix: pr comments * refactor: simplify doc * refactor: replace onApper with task * refactor: replace onChange with task id * refactor: simplify comments * refactor: simplyfy comments * refactor: simplify comments * refactor: simplify comments * refactor: simplify comments * refactor: remove multi-pair legacy code * fix: fallback to os widget options after remove in-app * fix: make chart height adaptable * feat: set backgroud color Gray7 * fix: reuse existing text component and remove scale factor * fix: display white32 checkmark for unselected item * fix: vertical padding anchored to checkbox image * fix: remove the gray bg and custom bg from Navigation bar * fix: try to fetch real data for preview * refactor: make string keys generic to be reused in the furue implementtions * fix: make prevew frame height adaptable * fix: remove app group fallback * test: widget test ids adjustment * fixes --------- Co-authored-by: Piotr Stachyra Co-authored-by: Philipp Walter --- Bitkit/Components/Widgets/PriceWidget.swift | 234 ++++++++-------- Bitkit/MainNavView.swift | 7 +- Bitkit/Models/PriceWidgetOptions.swift | 39 ++- .../Localization/en.lproj/Localizable.strings | 10 + Bitkit/Utilities/WidgetsBackupConverter.swift | 33 +-- Bitkit/ViewModels/WidgetsViewModel.swift | 20 +- .../Widgets/PriceWidgetPreviewView.swift | 256 ++++++++++++++++++ Bitkit/Views/Widgets/WidgetEditItemView.swift | 54 +++- Bitkit/Views/Widgets/WidgetEditLogic.swift | 26 +- Bitkit/Views/Widgets/WidgetEditModels.swift | 88 +++--- Bitkit/Views/Widgets/WidgetEditView.swift | 20 +- BitkitTests/WidgetsViewModelTests.swift | 32 +++ BitkitWidget/PriceHomeScreenWidget.swift | 248 ++++++++--------- BitkitWidget/PriceWidgetService.swift | 17 +- changelog.d/next/542.changed.md | 1 + 15 files changed, 708 insertions(+), 377 deletions(-) create mode 100644 Bitkit/Views/Widgets/PriceWidgetPreviewView.swift create mode 100644 BitkitTests/WidgetsViewModelTests.swift create mode 100644 changelog.d/next/542.changed.md diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index 8da348dab..eb75c65fc 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -1,21 +1,14 @@ import Charts import SwiftUI -/// A widget that displays cryptocurrency price information with chart +/// Displays Bitcoin price for the user's selected trading pair and timeframe. struct PriceWidget: View { - /// Configuration options for the widget var options: PriceWidgetOptions = .init() - - /// Flag indicating if the widget is in editing mode var isEditing: Bool = false - - /// Callback to signal when editing should end var onEditingEnd: (() -> Void)? - /// Price view model singleton @StateObject private var viewModel = PriceViewModel.shared - /// Initialize the widget init( options: PriceWidgetOptions = PriceWidgetOptions(), isEditing: Bool = false, @@ -32,91 +25,127 @@ struct PriceWidget: View { isEditing: isEditing, onEditingEnd: onEditingEnd ) { - VStack(spacing: 0) { - if viewModel.isLoading && filteredPriceData.isEmpty { - WidgetContentBuilder.loadingView() - } else if viewModel.error != nil { - WidgetContentBuilder.errorView(t("widgets__price__error")) - } else { - ForEach(filteredPriceData, id: \.name) { priceData in - PriceRow(data: priceData) - .accessibilityIdentifier("PriceWidgetRow-\(priceData.name)") - } - } - - if let firstPair = filteredPriceData.first { - PriceChart( - values: firstPair.pastValues, - isPositive: firstPair.change.isPositive, - period: options.selectedPeriod.rawValue - ) - .frame(height: 96) - .padding(.top, 8) - } - - if options.showSource { - WidgetContentBuilder.sourceRow(source: "Bitfinex.com") - .accessibilityIdentifier("PriceWidgetSource") - } - } + content } - .onAppear { - fetchPriceData() - } - .onChange(of: options.selectedPairs) { - fetchPriceData() - } - .onChange(of: options.selectedPeriod) { - fetchPriceData() + .task(id: options) { fetchPriceData() } + } + + @ViewBuilder + private var content: some View { + if viewModel.isLoading && primaryPrice == nil { + WidgetContentBuilder.loadingView() + } else if viewModel.error != nil { + WidgetContentBuilder.errorView(t("widgets__price__error")) + } else if let primary = primaryPrice { + PriceWidgetWideContent(data: primary, period: options.selectedPeriod) } } - private var filteredPriceData: [PriceData] { + /// Single pair. Falls back to first available data if the selection isn't loaded yet. + private var primaryPrice: PriceData? { let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod) - let dataByPair = Dictionary(uniqueKeysWithValues: currentPeriodData.map { ($0.name, $0) }) - return options.selectedPairs.compactMap { pair in - dataByPair[pair] + if let match = currentPeriodData.first(where: { $0.name == options.selectedPair }) { + return match } + return currentPeriodData.first } - /// Fetch price data from view model private func fetchPriceData() { - viewModel.fetchPriceData(pairs: options.selectedPairs, period: options.selectedPeriod) + viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) } } -// MARK: - Price Row Component +// MARK: - Wide layout (in-app + carousel page) -struct PriceRow: View { +struct PriceWidgetWideContent: View { let data: PriceData + let period: GraphPeriod var body: some View { - HStack { - BodySSBText(data.name, textColor: .textSecondary) + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 16) { + CaptionMText("\(data.name) \(period.rawValue)", textColor: .textSecondary) + .textCase(.uppercase) + .frame(maxWidth: .infinity, alignment: .leading) + + TitleText( + data.change.formatted, + textColor: data.change.isPositive ? .greenAccent : .redAccent + ) + .lineLimit(1) + .accessibilityIdentifier("price_card_pair_change_\(data.name)") + } + .accessibilityIdentifier("PriceWidgetRow-\(data.name)") + + Text(data.price) + .font(Fonts.bold(size: 34)) + .foregroundColor(.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("price_card_pair_price_\(data.name)") + } + + PriceChart(values: data.pastValues, isPositive: data.change.isPositive) + .frame(height: 48) + .accessibilityIdentifier("price_card_chart") + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Compact layout (small carousel preview only) + +struct PriceWidgetCompactContent: View { + let data: PriceData + let period: GraphPeriod - Spacer() + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 0) { + CaptionMText(data.name, textColor: .textSecondary) + .textCase(.uppercase) + Spacer(minLength: 0) + CaptionMText(period.rawValue, textColor: .textSecondary) + .textCase(.uppercase) + } + .accessibilityIdentifier("price_card_small_pair_row_\(data.name)") + + Text(data.price) + .font(Fonts.bold(size: 22)) + .foregroundColor(.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) + .accessibilityIdentifier("price_card_small_pair_price_\(data.name)") + + BodySSBText( + data.change.formatted, + textColor: data.change.isPositive ? .greenAccent : .redAccent + ) + .lineLimit(1) + .accessibilityIdentifier("price_card_small_pair_change_\(data.name)") + } - BodySSBText(data.change.formatted, textColor: data.change.isPositive ? .greenAccent : .redAccent) - .padding(.trailing, 8) - BodySSBText(data.price, textColor: .textPrimary) + PriceChart(values: data.pastValues, isPositive: data.change.isPositive) + .frame(height: 64) + .accessibilityIdentifier("price_card_small_chart") } - .frame(minHeight: 28) + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.gray6) + .cornerRadius(16) } } -// MARK: - Price Chart Component +// MARK: - Chart struct PriceChart: View { let values: [Double] let isPositive: Bool - let period: String - // Chart styling constants private let lineWidth: CGFloat = 1.3 - private let chartPadding: CGFloat = 4 - private let cornerRadius: CGFloat = 8 - private let gradientOpacityTop: CGFloat = 0.64 - private let gradientOpacityBottom: CGFloat = 0.08 private var normalizedValues: [Double] { guard values.count > 1 else { return values } @@ -127,76 +156,31 @@ struct PriceChart: View { guard range > 0 else { return values.map { _ in 0.5 } } - // Map to 0.15...0.85 range for more generous margins - // This prevents chart content from reaching the very edges where clipping occurs return values.map { value in let normalized = (value - minValue) / range - return 0.15 + (normalized * 0.7) // Maps 0-1 to 0.15-0.85 + return 0.15 + (normalized * 0.7) } } - private var chartColors: (gradient: [Color], line: Color) { - if isPositive { - return ( - gradient: [.greenAccent.opacity(gradientOpacityTop), .greenAccent.opacity(gradientOpacityBottom)], - line: .greenAccent - ) - } else { - return ( - gradient: [.redAccent.opacity(gradientOpacityTop), .redAccent.opacity(gradientOpacityBottom)], - line: .redAccent - ) - } + private var lineColor: Color { + isPositive ? .greenAccent : .redAccent } var body: some View { - ZStack(alignment: .bottomLeading) { - Chart { - ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in - // Area fill with gradient - AreaMark( - x: .value("Index", index), - y: .value("Price", value) - ) - .foregroundStyle( - LinearGradient( - colors: chartColors.gradient, - startPoint: .top, - endPoint: .bottom - ) - ) - .interpolationMethod(.catmullRom) - - // Line on top - LineMark( - x: .value("Index", index), - y: .value("Price", value) - ) - .foregroundStyle(chartColors.line) - .lineStyle(StrokeStyle(lineWidth: lineWidth)) - .interpolationMethod(.catmullRom) - } - } - .chartXAxis(.hidden) - .chartYAxis(.hidden) - // Y scale domain provides buffer zone beyond data range (0.15...0.85) - // This ensures chart elements (lines, curves) don't get clipped at edges - .chartYScale(domain: 0.1 ... 0.9) // Domain slightly larger than data range for extra buffer - // Apply rounded corners only to bottom - chart content extends to edges for visible clipping - // The internal margins above prevent any actual data from being cut off - .clipShape( - .rect( - topLeadingRadius: 0, - bottomLeadingRadius: cornerRadius, - bottomTrailingRadius: cornerRadius, - topTrailingRadius: 0 + Chart { + ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in + LineMark( + x: .value("Index", index), + y: .value("Price", value) ) - ) - - // Period label - CaptionBText(period, textColor: isPositive ? .green50 : .red50) - .padding(7) + .foregroundStyle(lineColor) + .lineStyle(StrokeStyle(lineWidth: lineWidth)) + .interpolationMethod(.catmullRom) + } } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartYScale(domain: 0.1 ... 0.9) } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index a787d4e9f..2a17eaa70 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -431,7 +431,12 @@ struct MainNavView: View { // Widgets case .widgetsIntro: WidgetsIntroView() case .widgetsList: WidgetsListView() - case let .widgetDetail(widgetType): WidgetDetailView(id: widgetType) + case let .widgetDetail(widgetType): + if widgetType == .price { + PriceWidgetPreviewView() + } else { + WidgetDetailView(id: widgetType) + } case let .widgetEdit(widgetType): WidgetEditView(id: widgetType) // Settings diff --git a/Bitkit/Models/PriceWidgetOptions.swift b/Bitkit/Models/PriceWidgetOptions.swift index 94310d05f..f4f90dcb0 100644 --- a/Bitkit/Models/PriceWidgetOptions.swift +++ b/Bitkit/Models/PriceWidgetOptions.swift @@ -1,8 +1,41 @@ import Foundation -/// Options for configuring the in-app and home-screen price widgets (shared via App Group for the extension). +/// Options for configuring the in-app and home-screen price widgets (shared via App Group). +/// struct PriceWidgetOptions: Codable, Equatable { - var selectedPairs: [String] = ["BTC/USD"] + var selectedPair: String = "BTC/USD" var selectedPeriod: GraphPeriod = .oneDay - var showSource: Bool = false + + init(selectedPair: String = "BTC/USD", selectedPeriod: GraphPeriod = .oneDay) { + self.selectedPair = selectedPair + self.selectedPeriod = selectedPeriod + } + + private enum CodingKeys: String, CodingKey { + case selectedPair + case selectedPairs // legacy v60 key + case selectedPeriod + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let pair = try container.decodeIfPresent(String.self, forKey: .selectedPair) { + selectedPair = pair + } else if let legacyPairs = try container.decodeIfPresent([String].self, forKey: .selectedPairs), + let first = legacyPairs.first + { + selectedPair = first + } else { + selectedPair = "BTC/USD" + } + + selectedPeriod = try container.decodeIfPresent(GraphPeriod.self, forKey: .selectedPeriod) ?? .oneDay + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(selectedPair, forKey: .selectedPair) + try container.encode(selectedPeriod, forKey: .selectedPeriod) + } } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 82914c7b1..4bcd88e86 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1391,6 +1391,16 @@ "widgets__price__name" = "Bitcoin Price"; "widgets__price__description" = "Check the latest Bitcoin exchange rates for a variety of fiat currencies."; "widgets__price__error" = "Couldn\'t get price data"; +"widgets__price__currency" = "Currency"; +"widgets__price__timeframe" = "Timeframe"; +"widgets__price__period_day" = "Day"; +"widgets__price__period_week" = "Week"; +"widgets__price__period_month" = "Month"; +"widgets__price__period_year" = "Year"; +"widgets__widget__size_small" = "Small"; +"widgets__widget__size_wide" = "Wide"; +"widgets__widget__settings" = "Widget Settings"; +"widgets__widget__save_widget" = "Save Widget"; "widgets__news__name" = "Bitcoin Headlines"; "widgets__news__description" = "Read the latest & greatest Bitcoin headlines from various news sites."; "widgets__news__error" = "Couldn\'t get the latest news"; diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 889a76095..6d0e392c8 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -59,14 +59,11 @@ enum WidgetsBackupConverter { } case .price: if let options = try? JSONDecoder().decode(PriceWidgetOptions.self, from: optionsData) { - let androidPairs = options.selectedPairs.map { pair in - pair.replacingOccurrences(of: "/", with: "_") - } + let androidPair = options.selectedPair.replacingOccurrences(of: "/", with: "_") let androidPeriod = convertIosPeriodToAndroid(options.selectedPeriod) pricePreferences = [ - "enabledPairs": androidPairs.isEmpty ? ["BTC_USD"] : androidPairs, + "enabledPairs": [androidPair.isEmpty ? "BTC_USD" : androidPair], "period": androidPeriod, - "showSource": options.showSource, ] } case .calculator, .suggestions: @@ -166,21 +163,20 @@ enum WidgetsBackupConverter { } case .price: if let prefs = jsonDict["pricePreferences"] as? [String: Any] { - var selectedPairs = ["BTC/USD"] - if let pairsArray = prefs["enabledPairs"] as? [String] { - selectedPairs = pairsArray.map { pairType in - pairType.replacingOccurrences(of: "_", with: "/") - } - if selectedPairs.isEmpty { - selectedPairs = ["BTC/USD"] + var selectedPair = "BTC/USD" + if let pairsArray = prefs["enabledPairs"] as? [String], + let firstAndroidPair = pairsArray.first + { + let converted = firstAndroidPair.replacingOccurrences(of: "_", with: "/") + if !converted.isEmpty { + selectedPair = converted } } let period = convertAndroidPeriodToIos(prefs["period"] as? String) let iosOptions = PriceWidgetOptions( - selectedPairs: selectedPairs, - selectedPeriod: period, - showSource: prefs["showSource"] as? Bool ?? false + selectedPair: selectedPair, + selectedPeriod: period ) optionsData = try? JSONEncoder().encode(iosOptions) } @@ -236,14 +232,11 @@ enum WidgetsBackupConverter { private static func getDefaultPricePreferences() -> [String: Any] { let defaults = PriceWidgetOptions() - let androidPairs = defaults.selectedPairs.map { pair in - pair.replacingOccurrences(of: "/", with: "_") - } + let androidPair = defaults.selectedPair.replacingOccurrences(of: "/", with: "_") let androidPeriod = convertIosPeriodToAndroid(defaults.selectedPeriod) return [ - "enabledPairs": androidPairs.isEmpty ? ["BTC_USD"] : androidPairs, + "enabledPairs": [androidPair.isEmpty ? "BTC_USD" : androidPair], "period": androidPeriod, - "showSource": defaults.showSource, ] } diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 67a6a804e..d42550ee2 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -188,9 +188,10 @@ class WidgetsViewModel: ObservableObject { // Don't add duplicates guard !isWidgetSaved(type) else { return } - let newSavedWidget = SavedWidget(type: type) - savedWidgetsWithOptions.append(newSavedWidget) - savedWidgets.append(newSavedWidget.toWidget()) + if !savedWidgetsWithOptions.contains(where: { $0.type == type }) { + savedWidgetsWithOptions.append(SavedWidget(type: type)) + } + savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() } @@ -254,6 +255,10 @@ class WidgetsViewModel: ObservableObject { } persistSavedWidgets() + + if type == .price, let priceOptions = options as? PriceWidgetOptions { + syncPriceOptionsToHomeScreenWidget(priceOptions) + } } catch { print("Failed to save widget options: \(error)") } @@ -301,7 +306,6 @@ class WidgetsViewModel: ObservableObject { savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() } - syncPriceOptionsToHomeScreenWidget() } private func persistSavedWidgets() { @@ -311,12 +315,12 @@ 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) + /// Mirrors in-app price widget options to the App Group so the home-screen WidgetKit widget can read them. + /// Only invoked when the user explicitly changes price widget options — adding, deleting, or resetting + /// in-app widgets must not affect the independent OS home-screen widget. + private func syncPriceOptionsToHomeScreenWidget(_ options: PriceWidgetOptions) { PriceHomeScreenWidgetOptionsStore.save(options) PriceHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift new file mode 100644 index 000000000..ef037893c --- /dev/null +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -0,0 +1,256 @@ +import SwiftUI + +/// Preview screen for the Bitcoin Price widget. +struct PriceWidgetPreviewView: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + + @StateObject private var viewModel = PriceViewModel.shared + + @State private var carouselPage: Int = 0 + @State private var showDeleteAlert = false + + private let widgetType: WidgetType = .price + + private var widgetName: String { + t("widgets__price__name") + } + + private var widgetDescription: String { + t("widgets__price__description") + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(widgetType) + } + + private var hasCustomOptions: Bool { + widgets.hasCustomOptions(for: widgetType) + } + + private var currentOptions: PriceWidgetOptions { + widgets.getOptions(for: widgetType, as: PriceWidgetOptions.self) + } + + private var primaryPrice: PriceData? { + let options = currentOptions + let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod) + if let match = currentPeriodData.first(where: { $0.name == options.selectedPair }) { + return match + } + return currentPeriodData.first + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + NavigationBar(title: widgetName, showMenuButton: false) + + VStack(alignment: .leading, spacing: 0) { + BodyMText(widgetDescription, textColor: .textSecondary) + .padding(.bottom, 16) + + Divider().background(Color.white.opacity(0.1)) + + widgetSettingsRow + + Divider().background(Color.white.opacity(0.1)) + } + + VStack(spacing: 16) { + carousel + + sizeLabel + + pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + buttonsRow + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .task { + let options = currentOptions + viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) + } + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteAlert, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } + Button(t("common__delete_yes"), role: .destructive) { onDelete() } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": widgetName])) + } + ) + } + + // MARK: - Widget Settings cell + + private var widgetSettingsRow: some View { + Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { + HStack(alignment: .center, spacing: 0) { + BodyMText(t("widgets__widget__settings"), textColor: .textPrimary) + + Spacer() + + BodyMText( + hasCustomOptions + ? t("widgets__widget__edit_custom") + : t("widgets__widget__edit_default"), + textColor: .textSecondary + ) + + Image("chevron") + .resizable() + .foregroundColor(.textSecondary) + .frame(width: 24, height: 24) + .padding(.leading, 5) + } + .frame(maxWidth: .infinity, minHeight: 51) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("WidgetEdit") + } + + // MARK: - Carousel + + private var carousel: some View { + TabView(selection: $carouselPage) { + compactPage + .tag(0) + + widePage + .tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(maxHeight: .infinity) + } + + private var compactPage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = primaryPrice { + PriceWidgetCompactContent(data: data, period: currentOptions.selectedPeriod) + } else { + placeholderCompact + } + } + .frame(width: 163, height: 192) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private var widePage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = primaryPrice { + PriceWidgetWideContent(data: data, period: currentOptions.selectedPeriod) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + placeholderWide + } + } + .frame(maxWidth: .infinity) + Spacer(minLength: 0) + } + } + + private var placeholderCompact: some View { + Color.gray6 + .cornerRadius(16) + .overlay(ProgressView()) + } + + private var placeholderWide: some View { + Color.gray6 + .cornerRadius(16) + .frame(height: 152) + .overlay(ProgressView()) + } + + // MARK: - Size label & page indicator + + private var sizeLabel: some View { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__widget__size_small") + : t("widgets__widget__size_wide"), + textColor: .textSecondary + ) + .textCase(.uppercase) + Spacer() + } + } + + private var pageIndicator: some View { + HStack(spacing: 8) { + Spacer() + ForEach(0 ..< 2, id: \.self) { index in + Circle() + .fill(carouselPage == index ? Color.brandAccent : Color.white.opacity(0.32)) + .frame(width: 8, height: 8) + } + Spacer() + } + } + + // MARK: - Buttons + + private var buttonsRow: some View { + HStack(spacing: 16) { + if isWidgetSaved { + CustomButton( + title: t("common__delete"), + variant: .secondary, + size: .large, + shouldExpand: true + ) { + showDeleteAlert = true + } + .accessibilityIdentifier("WidgetDelete") + } + + CustomButton( + title: t("widgets__widget__save_widget"), + variant: .primary, + size: .large, + shouldExpand: true, + action: onSave + ) + .accessibilityIdentifier("WidgetSave") + } + } + + // MARK: - Actions + + private func onSave() { + widgets.saveWidget(widgetType) + navigation.reset() + } + + private func onDelete() { + widgets.deleteWidget(widgetType) + navigation.reset() + } +} + +#Preview { + NavigationStack { + PriceWidgetPreviewView() + .environmentObject(NavigationViewModel()) + .environmentObject(WidgetsViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Widgets/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index 423477dbe..8aad26a02 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -5,7 +5,23 @@ struct WidgetEditItemView: View { let onToggle: () -> Void var body: some View { - let content = VStack(spacing: 0) { + switch item.type { + case .sectionHeader: + item.titleView + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 16) + case .staticItem: + row + case .toggleItem, .radioItem: + Button(action: onToggle) { + row + } + .buttonStyle(PlainButtonStyle()) + } + } + + private var row: some View { + VStack(spacing: 8) { HStack(spacing: 16) { item.titleView .frame(maxWidth: .infinity, alignment: .leading) @@ -17,24 +33,36 @@ struct WidgetEditItemView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - Image("check-mark") - .resizable() - .foregroundColor(item.isChecked ? .brandAccent : .gray3) - .frame(width: 32, height: 32) + accessoryView } - .padding(.vertical, 16) + .frame(minHeight: 32) .contentShape(Rectangle()) - Divider() + CustomDivider() } + .padding(.top, 8) + } - if item.type == .staticItem { - content - } else { - Button(action: onToggle) { - content + @ViewBuilder + private var accessoryView: some View { + switch item.type { + case .toggleItem: + Image("check-mark") + .resizable() + .foregroundColor(item.isChecked ? .brandAccent : .gray3) + .frame(width: 32, height: 32) + case .radioItem: + if item.isChecked { + Image("check-mark") + .resizable() + .foregroundColor(.brandAccent) + .frame(width: 32, height: 32) + } else { + Color.clear + .frame(width: 32, height: 32) } - .buttonStyle(PlainButtonStyle()) + case .staticItem, .sectionHeader: + EmptyView() } } } diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index 3820ab6f0..1e5478102 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -44,8 +44,8 @@ class WidgetEditLogic: ObservableObject { // Weather widget has multiple options, check if any are enabled return weatherOptions.showStatus || weatherOptions.showText || weatherOptions.showMedian || weatherOptions.showNextBlockFee case .price: - // Price widget has options, check if at least one trading pair is selected - return !priceOptions.selectedPairs.isEmpty + // Price widget always has a selected pair (single-select). + return true case .calculator, .suggestions: return false } @@ -77,7 +77,7 @@ class WidgetEditLogic: ObservableObject { func toggleOption(_ item: WidgetEditItem) { // Don't toggle static items - guard item.type == .toggleItem else { return } + guard item.type != .staticItem else { return } switch widgetType { case .blocks: @@ -138,14 +138,8 @@ class WidgetEditLogic: ObservableObject { } case .price: switch item.key { - case "BTC/USD": - toggleTradingPair("BTC/USD") - case "BTC/EUR": - toggleTradingPair("BTC/EUR") - case "BTC/GBP": - toggleTradingPair("BTC/GBP") - case "BTC/JPY": - toggleTradingPair("BTC/JPY") + case "BTC/USD", "BTC/EUR", "BTC/GBP", "BTC/JPY": + selectTradingPair(item.key) case "1D": priceOptions.selectedPeriod = .oneDay case "1W": @@ -154,8 +148,6 @@ class WidgetEditLogic: ObservableObject { priceOptions.selectedPeriod = .oneMonth case "1Y": priceOptions.selectedPeriod = .oneYear - case "showSource": - priceOptions.showSource.toggle() default: break } @@ -165,12 +157,8 @@ class WidgetEditLogic: ObservableObject { onStateChange?() } - private func toggleTradingPair(_ pairName: String) { - if priceOptions.selectedPairs.contains(pairName) { - priceOptions.selectedPairs.removeAll { $0 == pairName } - } else { - priceOptions.selectedPairs.append(pairName) - } + private func selectTradingPair(_ pairName: String) { + priceOptions.selectedPair = pairName } func loadCurrentOptions() { diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 575f500b6..9757a5ad9 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -1,10 +1,28 @@ import SwiftUI +// MARK: - GraphPeriod display + +extension GraphPeriod { + /// Full-word label shown in the Price edit screen (Day / Week / Month / Year). + /// The widget itself uses `rawValue` ("1D"/...) per Figma v61. + var editScreenLabel: String { + switch self { + case .oneDay: return t("widgets__price__period_day") + case .oneWeek: return t("widgets__price__period_week") + case .oneMonth: return t("widgets__price__period_month") + case .oneYear: return t("widgets__price__period_year") + } + } +} + // MARK: - Widget Edit Item Models enum WidgetItemType { case toggleItem + case radioItem case staticItem + /// Non-tappable section header (uppercase caption above a group of items). + case sectionHeader } struct WidgetEditItem { @@ -357,68 +375,62 @@ enum WidgetEditItemFactory { } @MainActor - static func getPriceItems(priceOptions: PriceWidgetOptions, priceDataByPeriod: [GraphPeriod: [PriceData]] = [:]) -> [WidgetEditItem] { + static func getPriceItems(priceOptions: PriceWidgetOptions, priceDataByPeriod _: [GraphPeriod: [PriceData]] = [:]) -> [WidgetEditItem] { var items: [WidgetEditItem] = [] - // Trading pair options with live or fallback prices - let fallbackPrices = ["$ 43,250", "€ 39,850", "£ 34,120", "¥ 6,245,000"] - - // Use current period data for trading pair prices - let currentPeriodData = priceDataByPeriod[priceOptions.selectedPeriod] ?? [] - - for (index, pair) in tradingPairNames.enumerated() { - // Try to find live data for this pair - let livePrice = currentPeriodData.first { $0.name == pair }?.price ?? fallbackPrices[index] + // CURRENCY section (single-select) + items.append(sectionHeaderItem(key: "currency_header", title: t("widgets__price__currency"))) + let selectedPair = priceOptions.selectedPair + for pair in tradingPairNames { + let isSelected = selectedPair == pair items.append( WidgetEditItem( key: pair, - type: .toggleItem, - title: pair, - value: livePrice, - isChecked: priceOptions.selectedPairs.contains(pair) + type: .radioItem, + titleView: AnyView( + BodySSBText(pair, textColor: isSelected ? .textPrimary : .textSecondary) + ), + valueView: nil, + isChecked: isSelected ) ) } - // Period selection (radio group) with charts - let periods: [GraphPeriod] = [.oneDay, .oneWeek, .oneMonth, .oneYear] - - for period in periods { - // Get data for this specific period - let periodData = priceDataByPeriod[period] ?? [] - let firstPairData = periodData.first + items.append(sectionHeaderItem(key: "timeframe_header", title: t("widgets__price__timeframe"), topInset: 16)) + for period in GraphPeriod.allCases { + let isSelected = priceOptions.selectedPeriod == period items.append( WidgetEditItem( key: period.rawValue, - type: .toggleItem, + type: .radioItem, titleView: AnyView( - PriceChart( - values: firstPairData?.pastValues ?? [], - isPositive: firstPairData?.change.isPositive ?? true, - period: period.rawValue - ) + BodySSBText(period.editScreenLabel, textColor: isSelected ? .textPrimary : .textSecondary) ), valueView: nil, - isChecked: priceOptions.selectedPeriod == period + isChecked: isSelected ) ) } - items.append( - WidgetEditItem( - key: "showSource", - type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("Bitfinex.com", textColor: .textSecondary)), - isChecked: priceOptions.showSource - ) - ) - return items } + private static func sectionHeaderItem(key: String, title: String, topInset: CGFloat = 0) -> WidgetEditItem { + WidgetEditItem( + key: key, + type: .sectionHeader, + titleView: AnyView( + CaptionMText(title, textColor: .textSecondary) + .textCase(.uppercase) + .padding(.top, topInset) + ), + valueView: nil, + isChecked: false + ) + } + @MainActor static func getWeatherItems( weatherViewModel: WeatherViewModel, diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index 8cfc81174..09152b67e 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -57,15 +57,20 @@ struct WidgetEditView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("widgets__widget__edit")) - .padding(.bottom, 16) - - BodyMText( - t("widgets__widget__edit_description", variables: ["name": widget.name]), - textColor: .textSecondary + NavigationBar( + title: id == .price ? widget.name : t("widgets__widget__edit"), + showMenuButton: id != .price ) .padding(.bottom, 16) + if id != .price { + BodyMText( + t("widgets__widget__edit_description", variables: ["name": widget.name]), + textColor: .textSecondary + ) + .padding(.bottom, 16) + } + ScrollView(showsIndicators: false) { LazyVStack(spacing: 0) { ForEach(getItems(), id: \.key) { item in @@ -73,7 +78,7 @@ struct WidgetEditView: View { item: item, onToggle: { editLogic?.toggleOption(item) } ) - .accessibilityIdentifier("WidgetEditField-\(item.key)") + .accessibilityIdentifier("\(item.key)_setting_row") } } .id(refreshTrigger) // Force refresh when refreshTrigger changes @@ -107,6 +112,7 @@ struct WidgetEditView: View { } .navigationBarHidden(true) .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { if editLogic == nil { let logic = WidgetEditLogic(widgetType: id, widgetsViewModel: widgets) diff --git a/BitkitTests/WidgetsViewModelTests.swift b/BitkitTests/WidgetsViewModelTests.swift new file mode 100644 index 000000000..6f97925de --- /dev/null +++ b/BitkitTests/WidgetsViewModelTests.swift @@ -0,0 +1,32 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class WidgetsViewModelTests: XCTestCase { + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: "savedWidgets") + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: "savedWidgets") + super.tearDown() + } + + func testSavingWidgetAfterEditingUnsavedOptionsDoesNotDuplicateAfterReload() { + let widgets = WidgetsViewModel() + widgets.deleteWidget(.suggestions) + widgets.deleteWidget(.price) + widgets.deleteWidget(.blocks) + + widgets.saveOptions(PriceWidgetOptions(selectedPair: "BTC/EUR", selectedPeriod: .oneWeek), for: .price) + widgets.saveWidget(.price) + + let reloadedWidgets = WidgetsViewModel() + let priceWidgets = reloadedWidgets.savedWidgets.filter { $0.type == .price } + let options: PriceWidgetOptions = reloadedWidgets.getOptions(for: .price, as: PriceWidgetOptions.self) + + XCTAssertEqual(priceWidgets.count, 1) + XCTAssertEqual(options, PriceWidgetOptions(selectedPair: "BTC/EUR", selectedPeriod: .oneWeek)) + } +} diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index b53fea3f8..f30057c11 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -34,23 +34,43 @@ struct PriceWidgetProvider: TimelineProvider { }() func placeholder(in _: Context) -> PriceWidgetEntry { - Self.mockEntry + let options = PriceHomeScreenWidgetOptionsStore.load() + if let cached = PriceWidgetService.cachedPrices(pairs: [options.selectedPair], period: options.selectedPeriod), + !cached.isEmpty + { + return PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: false) + } + return Self.mockEntry } func getSnapshot(in context: Context, completion: @escaping (PriceWidgetEntry) -> Void) { let options = PriceHomeScreenWidgetOptionsStore.load() + let cached = PriceWidgetService.cachedPrices(pairs: [options.selectedPair], period: options.selectedPeriod) ?? [] + + if !cached.isEmpty { + completion(PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: false)) + return + } if context.isPreview { - completion(PriceWidgetEntry( - date: Self.mockEntry.date, - prices: Self.mockEntry.prices, - options: options, - showsError: false - )) + Task { + if let fresh = try? await PriceWidgetService.fetchFreshPrices( + pairs: [options.selectedPair], + period: options.selectedPeriod + ), !fresh.isEmpty { + completion(PriceWidgetEntry(date: Date(), prices: fresh, options: options, showsError: false)) + } else { + 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)) } @@ -61,12 +81,12 @@ struct PriceWidgetProvider: TimelineProvider { let entry: PriceWidgetEntry do { let fresh = try await PriceWidgetService.fetchFreshPrices( - pairs: options.selectedPairs, + pairs: [options.selectedPair], period: options.selectedPeriod ) entry = PriceWidgetEntry(date: Date(), prices: fresh, options: options, showsError: false) } catch { - let cached = PriceWidgetService.cachedPrices(pairs: options.selectedPairs, period: options.selectedPeriod) ?? [] + let cached = PriceWidgetService.cachedPrices(pairs: [options.selectedPair], period: options.selectedPeriod) ?? [] entry = PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: cached.isEmpty) } @@ -86,93 +106,105 @@ struct PriceHomeScreenWidgetEntryView: View { 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 } + content + .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 { + } else if let primary = primaryPrice { switch widgetFamily { case .systemSmall: - smallContent + compactLayout(data: primary) default: - rowsAndChart + wideLayout(data: primary) } + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + + private var primaryPrice: PriceData? { + if let match = entry.prices.first(where: { $0.name == entry.options.selectedPair }) { + return match } + return entry.prices.first } - // MARK: - Variants + // MARK: - Compact (small widget — 163×192) - 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) + private func compactLayout(data: PriceData) -> some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 0) { + CaptionMText(data.name, textColor: secondaryTextColor) + Spacer(minLength: 0) + CaptionMText(entry.options.selectedPeriod.rawValue, textColor: secondaryTextColor) + } - TitleText(primary?.price ?? "—", textColor: valueTextColor) - .lineLimit(1) - .minimumScaleFactor(0.7) - .widgetAccentable() + priceText(data.price, size: 22) - if let change = primary?.change { - BodySSBText(change.formatted, textColor: changeColor(isPositive: change.isPositive)) + Text(data.change.formatted) + .font(Fonts.semiBold(size: 15)) + .foregroundColor(changeColor(isPositive: data.change.isPositive)) .lineLimit(1) .widgetAccentable() } - Spacer(minLength: 0) + Spacer(minLength: 8) + + chart(values: data.pastValues, isPositive: data.change.isPositive, idealHeight: 64) } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private var rowsAndChart: some View { - VStack(spacing: 0) { - ForEach(visibleRows, id: \.name) { data in - priceRow(data: data) - } + // MARK: - Wide (medium widget — 343×152) - 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 func wideLayout(data: PriceData) -> some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 16) { + CaptionMText("\(data.name) \(entry.options.selectedPeriod.rawValue)", textColor: secondaryTextColor) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(data.change.formatted) + .font(Fonts.bold(size: 22)) + .foregroundColor(changeColor(isPositive: data.change.isPositive)) + .lineLimit(1) + .widgetAccentable() + } + + priceText(data.price, size: 34) } + + Spacer(minLength: 4) + + chart(values: data.pastValues, isPositive: data.change.isPositive, idealHeight: 48, minHeight: 24) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - 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)) - } + // MARK: - Sub-views + + private func priceText(_ value: String, size: CGFloat) -> some View { + Text(value) + .font(Fonts.bold(size: size)) + .foregroundColor(valueTextColor) + .lineLimit(1) + .widgetAccentable() } - private var chartHeight: CGFloat { - switch widgetFamily { - case .systemMedium: 64 - case .systemLarge, .systemExtraLarge: 120 - default: 96 - } + private func chart(values: [Double], isPositive: Bool, idealHeight: CGFloat, minHeight: CGFloat = 32) -> some View { + PriceWidgetChart( + values: values, + isPositive: isPositive, + renderingMode: widgetRenderingMode + ) + .frame(minHeight: minHeight, maxHeight: idealHeight) + .widgetAccentable() } private var errorView: some View { @@ -180,27 +212,6 @@ struct PriceHomeScreenWidgetEntryView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - // MARK: - Row - - private func priceRow(data: PriceData) -> some View { - HStack(spacing: 0) { - BodySSBText(data.name, textColor: secondaryTextColor) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - - BodySSBText(data.change.formatted, textColor: changeColor(isPositive: data.change.isPositive)) - .padding(.trailing, 8) - .lineLimit(1) - .widgetAccentable() - - BodySSBText(data.price, textColor: valueTextColor) - .lineLimit(1) - .minimumScaleFactor(0.75) - .widgetAccentable() - } - .frame(minHeight: 24) - } - // MARK: - Colors private var backgroundView: some View { @@ -226,7 +237,6 @@ struct PriceHomeScreenWidgetEntryView: View { private struct PriceWidgetChart: View { let values: [Double] let isPositive: Bool - let period: String let renderingMode: WidgetRenderingMode private var normalizedValues: [Double] { @@ -243,55 +253,21 @@ private struct PriceWidgetChart: View { 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 + Chart { + ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in + LineMark( + x: .value("Index", index), + y: .value("Price", value) ) - ) - .widgetAccentable() - - CaptionBText(period, textColor: labelColor) - .padding(7) + .foregroundStyle(lineColor) + .lineStyle(StrokeStyle(lineWidth: 1.3)) + .interpolationMethod(.catmullRom) + } } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartYScale(domain: 0.1 ... 0.9) } } @@ -307,6 +283,6 @@ struct BitkitPriceWidget: Widget { } .configurationDisplayName("Bitcoin Price") .description("Latest Bitcoin price and chart, mirroring the in-app price widget.") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .supportedFamilies([.systemSmall, .systemMedium]) } } diff --git a/BitkitWidget/PriceWidgetService.swift b/BitkitWidget/PriceWidgetService.swift index 0574bdf22..c07ba499f 100644 --- a/BitkitWidget/PriceWidgetService.swift +++ b/BitkitWidget/PriceWidgetService.swift @@ -22,16 +22,19 @@ enum PriceWidgetService { // 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) } + let results = await withTaskGroup(of: (Int, PriceData?).self) { group -> [PriceData] in + for (index, pair) in pairs.enumerated() { + group.addTask { + let data = try? await fetchPair(pairName: pair, period: period) + return (index, data) + } } - var collected: [PriceData] = [] - for await result in group { - if let result { collected.append(result) } + var collected: [(Int, PriceData)] = [] + for await (index, result) in group { + if let result { collected.append((index, result)) } } - return collected + return collected.sorted { $0.0 < $1.0 }.map(\.1) } guard !results.isEmpty else { throw FetchError.noPriceDataAvailable } diff --git a/changelog.d/next/542.changed.md b/changelog.d/next/542.changed.md new file mode 100644 index 000000000..d6bc025ee --- /dev/null +++ b/changelog.d/next/542.changed.md @@ -0,0 +1 @@ +Redesign the Bitcoin Price widget (in-app and home screen) to match Figma v61: single-currency selection, dedicated wide/compact layouts, line-only chart, and an updated edit and preview flow. From 42bc665309921d53443a7b4aa792a6abcdfa0467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Sena?= Date: Fri, 15 May 2026 03:49:47 -0400 Subject: [PATCH 53/60] feat: redesign headlines widget v61 + OS widget (#546) * feat: port price widgets related screens to figma V61 * fix: spacing and alignment * feat: hide menu button from nabigation bar * fix: padding * fix: remove systemLarge widget option * fix: collect results in input order instead of completion order * fix: pr comments * fix: pr comments * refactor: simplify doc * refactor: replace onApper with task * refactor: replace onChange with task id * refactor: simplify comments * refactor: simplyfy comments * refactor: simplify comments * refactor: simplify comments * refactor: simplify comments * refactor: remove multi-pair legacy code * fix: fallback to os widget options after remove in-app * fix: make chart height adaptable * feat: set backgroud color Gray7 * feat: migrate news widget to design v61 and port OS widget * fix: push source text to bottom * refactor: extract articles url to a shared files * feat: open browser on widget click * doc: changelog entry * fix: small and medium sizes displaying different random url * chore: remove schedule file * fix: replace onAppear with task * fix: use stable dafe format identifier * fix: reuse existing text component and remove scale factor * fix: display white32 checkmark for unselected item * fix: vertical padding anchored to checkbox image * fix: remove the gray bg and custom bg from Navigation bar * fix: try to fetch real data for preview * refactor: make string keys generic to be reused in the furue implementtions * fix: make prevew frame height adaptable * fix: display checkmark for title * fix: remove app group fallback * test: widget test ids adjustment * test: widget test ids adjustment * fixes * fixes * fixes --------- Co-authored-by: Piotr Stachyra Co-authored-by: Philipp Walter --- Bitkit.xcodeproj/project.pbxproj | 3 + Bitkit/Components/Widgets/NewsWidget.swift | 116 +++++--- Bitkit/Constants/WidgetEnv.swift | 2 + Bitkit/MainNavView.swift | 13 +- Bitkit/Models/NewsWidgetData.swift | 40 +++ Bitkit/Models/NewsWidgetOptions.swift | 14 + .../Localization/en.lproj/Localizable.strings | 2 +- .../NewsHomeScreenWidgetOptionsStore.swift | 36 +++ Bitkit/Services/Widgets/NewsService.swift | 117 +++----- Bitkit/ViewModels/WidgetsViewModel.swift | 12 + .../Views/Widgets/NewsWidgetPreviewView.swift | 246 ++++++++++++++++ .../Widgets/PriceWidgetPreviewView.swift | 2 +- Bitkit/Views/Widgets/WidgetEditItemView.swift | 4 +- Bitkit/Views/Widgets/WidgetEditLogic.swift | 6 +- Bitkit/Views/Widgets/WidgetEditModels.swift | 44 +-- Bitkit/Views/Widgets/WidgetEditView.swift | 8 - BitkitWidget/BitkitWidget.swift | 1 + BitkitWidget/NewsHomeScreenWidget.swift | 277 ++++++++++++++++++ BitkitWidget/NewsWidgetService.swift | 58 ++++ changelog.d/next/546.added.md | 1 + 20 files changed, 853 insertions(+), 149 deletions(-) create mode 100644 Bitkit/Models/NewsWidgetData.swift create mode 100644 Bitkit/Models/NewsWidgetOptions.swift create mode 100644 Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift create mode 100644 Bitkit/Views/Widgets/NewsWidgetPreviewView.swift create mode 100644 BitkitWidget/NewsHomeScreenWidget.swift create mode 100644 BitkitWidget/NewsWidgetService.swift create mode 100644 changelog.d/next/546.added.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 799da4457..15e22deb6 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -173,8 +173,11 @@ Fonts/InterTight-Regular.ttf, Constants/WidgetEnv.swift, Fonts/InterTight-SemiBold.ttf, + Models/NewsWidgetData.swift, + Models/NewsWidgetOptions.swift, Models/PriceWidgetData.swift, Models/PriceWidgetOptions.swift, + Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift, Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift, Styles/Colors.swift, Styles/Fonts.swift, diff --git a/Bitkit/Components/Widgets/NewsWidget.swift b/Bitkit/Components/Widgets/NewsWidget.swift index 1a64e748d..b111e1492 100644 --- a/Bitkit/Components/Widgets/NewsWidget.swift +++ b/Bitkit/Components/Widgets/NewsWidget.swift @@ -1,27 +1,13 @@ import SwiftUI -/// Options for configuring the NewsWidget -struct NewsWidgetOptions: Codable, Equatable { - var showDate: Bool = true - var showTitle: Bool = true - var showSource: Bool = true -} - -/// A widget that displays a news article +/// A widget that displays a news article. struct NewsWidget: View { - /// Configuration options for the widget var options: NewsWidgetOptions = .init() - - /// Flag indicating if the widget is in editing mode var isEditing: Bool = false - - /// Callback to signal when editing should end var onEditingEnd: (() -> Void)? - /// View model for handling news data @StateObject private var viewModel = NewsViewModel.shared - /// Initialize the widget init( options: NewsWidgetOptions = NewsWidgetOptions(), isEditing: Bool = false, @@ -38,40 +24,92 @@ struct NewsWidget: View { isEditing: isEditing, onEditingEnd: onEditingEnd ) { - VStack(spacing: 0) { - if viewModel.isLoading { - WidgetContentBuilder.loadingView() - } else if viewModel.error != nil { - WidgetContentBuilder.errorView(t("widgets__news__error")) - } else if let data = viewModel.widgetData { - if options.showDate { - BodyMText(data.timeAgo, textColor: .textPrimary) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 16) + content + .contentShape(Rectangle()) + .onTapGesture { + if !isEditing, let data = viewModel.widgetData, let url = URL(string: data.link) { + UIApplication.shared.open(url) } + } + } + .task { + viewModel.startUpdates() + } + } - if options.showTitle { - TitleText(data.title) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - } + @ViewBuilder + private var content: some View { + if viewModel.isLoading && viewModel.widgetData == nil { + WidgetContentBuilder.loadingView() + } else if viewModel.error != nil { + WidgetContentBuilder.errorView(t("widgets__news__error")) + } else if let data = viewModel.widgetData { + NewsWidgetWideContent(data: data, options: options) + } + } +} + +// MARK: - Wide layout (in-app + 343-wide carousel page) + +struct NewsWidgetWideContent: View { + let data: WidgetData + let options: NewsWidgetOptions + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if options.showTitle { + TitleText(data.title) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .leading) + } + if options.showSource || options.showDate { + HStack(alignment: .center, spacing: 8) { if options.showSource { - WidgetContentBuilder.sourceRow(source: data.publisher) + BodySSBText(data.publisher, textColor: .brandAccent) + .lineLimit(1) + } + Spacer(minLength: 0) + if options.showDate { + BodySSBText(data.timeAgo, textColor: .textSecondary) + .lineLimit(1) } } + .frame(maxWidth: .infinity) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Compact layout (small carousel preview + 163×192 OS widget) + +struct NewsWidgetCompactContent: View { + let data: WidgetData + let options: NewsWidgetOptions + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if options.showTitle { + TitleText(data.title) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .leading) } - .contentShape(Rectangle()) - .onTapGesture { - if !isEditing, let data = viewModel.widgetData, let url = URL(string: data.link) { - UIApplication.shared.open(url) + + Spacer(minLength: 8) + + if options.showDate { + HStack { + Spacer(minLength: 0) + BodySSBText(data.timeAgo, textColor: .textSecondary) + .lineLimit(1) } } } - .onAppear { - viewModel.startUpdates() - } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.gray6) + .cornerRadius(16) } } diff --git a/Bitkit/Constants/WidgetEnv.swift b/Bitkit/Constants/WidgetEnv.swift index 1c7e30320..19eb7e1ae 100644 --- a/Bitkit/Constants/WidgetEnv.swift +++ b/Bitkit/Constants/WidgetEnv.swift @@ -7,4 +7,6 @@ import Foundation /// 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" + static let newsFeedBaseUrl = "https://feeds.synonym.to/news-feed/api" + static let newsFeedArticlesUrl = "\(newsFeedBaseUrl)/articles" } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 2a17eaa70..ba6c2e3f4 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -246,6 +246,12 @@ struct MainNavView: View { Task { Logger.info("Received deeplink: \(url.absoluteString)") + // Web URLs from widgets (e.g. news article tap) bypass payment handling + if let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { + await UIApplication.shared.open(url) + return + } + if let callback = PubkyRingAuthCallback.parse(url: url) { let handlingResult = await pubkyProfile.handleAuthCallback(callback) @@ -432,9 +438,12 @@ struct MainNavView: View { case .widgetsIntro: WidgetsIntroView() case .widgetsList: WidgetsListView() case let .widgetDetail(widgetType): - if widgetType == .price { + switch widgetType { + case .price: PriceWidgetPreviewView() - } else { + case .news: + NewsWidgetPreviewView() + default: WidgetDetailView(id: widgetType) } case let .widgetEdit(widgetType): WidgetEditView(id: widgetType) diff --git a/Bitkit/Models/NewsWidgetData.swift b/Bitkit/Models/NewsWidgetData.swift new file mode 100644 index 000000000..1db970135 --- /dev/null +++ b/Bitkit/Models/NewsWidgetData.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Persistable representation of a news article shared between the main app and the widget extension via App Group. +struct CachedNewsArticle: Codable, Equatable { + let title: String + let publisher: String + let link: String + let publishedDate: String + let publishedEpoch: Int +} + +/// Cache reader/writer used by both the main app and the widget extension. +enum NewsWidgetCache { + static let appGroupSuiteName = "group.bitkit" + private static let topArticlesKey = "news_widget_top_articles_v1" + private static let legacyStandardKey = "news_widget_cache" + + private static func defaults() -> UserDefaults { + UserDefaults(suiteName: appGroupSuiteName) ?? .standard + } + + static func saveTop(_ articles: [CachedNewsArticle]) { + guard let encoded = try? JSONEncoder().encode(articles) else { return } + defaults().set(encoded, forKey: topArticlesKey) + } + + static func loadTop() -> [CachedNewsArticle] { + guard let data = defaults().data(forKey: topArticlesKey), + let decoded = try? JSONDecoder().decode([CachedNewsArticle].self, from: data) + else { + return [] + } + return decoded + } + + /// One-time cleanup of the pre-App-Group single-`WidgetData` cache. + static func legacyDropStandardSuiteCache() { + UserDefaults.standard.removeObject(forKey: legacyStandardKey) + } +} diff --git a/Bitkit/Models/NewsWidgetOptions.swift b/Bitkit/Models/NewsWidgetOptions.swift new file mode 100644 index 000000000..4497b6b50 --- /dev/null +++ b/Bitkit/Models/NewsWidgetOptions.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Options for configuring the in-app and home-screen news widgets (shared via App Group). +struct NewsWidgetOptions: Codable, Equatable { + var showDate: Bool = true + var showTitle: Bool = true + var showSource: Bool = true + + init(showDate: Bool = true, showTitle: Bool = true, showSource: Bool = true) { + self.showDate = showDate + self.showTitle = showTitle + self.showSource = showSource + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 4bcd88e86..842a2462b 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1382,7 +1382,6 @@ "widgets__widget__edit" = "Widget Feed"; "widgets__widget__edit_default" = "Default"; "widgets__widget__edit_custom" = "Custom"; -"widgets__widget__edit_description" = "Please select which fields you want to display in the {name} widget."; "widgets__widget__source" = "Source"; "widgets__add" = "Add Widget"; "widgets__list__button" = "Enable In Settings"; @@ -1404,6 +1403,7 @@ "widgets__news__name" = "Bitcoin Headlines"; "widgets__news__description" = "Read the latest & greatest Bitcoin headlines from various news sites."; "widgets__news__error" = "Couldn\'t get the latest news"; +"widgets__news__content_header" = "Content"; "widgets__blocks__name" = "Bitcoin Blocks"; "widgets__blocks__description" = "Examine various statistics on newly mined Bitcoin Blocks."; "widgets__blocks__error" = "Couldn\'t get blocks data"; diff --git a/Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift new file mode 100644 index 000000000..043d161dd --- /dev/null +++ b/Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift @@ -0,0 +1,36 @@ +import Foundation +import WidgetKit + +/// Mirrors in-app news widget options into the App Group so the WidgetKit extension can read them, +/// and centralizes the WidgetKit reload trigger for the news home-screen widget. +enum NewsHomeScreenWidgetOptionsStore { + /// WidgetKit `kind` for the home-screen news widget (must match `BitkitNewsWidget`). + static let newsHomeScreenWidgetKind = "BitkitNewsWidget" + + private static let suiteName = "group.bitkit" + private static let key = "home_screen_news_widget_options_v1" + + static func save(_ options: NewsWidgetOptions) { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = try? JSONEncoder().encode(options) + else { return } + defaults.set(data, forKey: key) + } + + static func load() -> NewsWidgetOptions { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = defaults.data(forKey: key), + let options = try? JSONDecoder().decode(NewsWidgetOptions.self, from: data) + else { + return NewsWidgetOptions() + } + 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: newsHomeScreenWidgetKind) + } +} diff --git a/Bitkit/Services/Widgets/NewsService.swift b/Bitkit/Services/Widgets/NewsService.swift index e02681ab3..01ee7a650 100644 --- a/Bitkit/Services/Widgets/NewsService.swift +++ b/Bitkit/Services/Widgets/NewsService.swift @@ -3,24 +3,22 @@ import Foundation /// Service for fetching and caching news articles class NewsService { static let shared = NewsService() - private let cache = UserDefaults.standard - private let cacheKey = "news_widget_cache" - private let baseUrl = "https://feeds.synonym.to/news-feed/api" private let refreshInterval: TimeInterval = 2 * 60 // 2 minutes - private init() {} + private init() { + NewsWidgetCache.legacyDropStandardSuiteCache() + } /// Fetches articles from the news API /// - Returns: Array of articles /// - Throws: URLError or decoding error func fetchArticles() async throws -> [Article] { - guard let url = URL(string: "\(baseUrl)/articles") else { + guard let url = URL(string: WidgetEnv.newsFeedArticlesUrl) else { throw URLError(.badURL) } let (data, response) = try await URLSession.shared.data(from: url) - // Validate HTTP response guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } @@ -29,39 +27,18 @@ class NewsService { throw URLError(.badServerResponse) } - do { - let decoder = JSONDecoder() - return try decoder.decode([Article].self, from: data) - } catch { - throw error - } + return try JSONDecoder().decode([Article].self, from: data) } - /// Caches widget data to UserDefaults - /// - Parameter data: Widget data to cache - func cacheData(_ data: WidgetData) { - do { - let encoder = JSONEncoder() - let encoded = try encoder.encode(data) - cache.set(encoded, forKey: cacheKey) - } catch { - // Handle silently - } - } - - /// Retrieves cached widget data - /// - Returns: Widget data if available + /// Retrieves a cached widget data view by selecting a random article from the App Group cache. func getCachedData() -> WidgetData? { - guard let data = cache.data(forKey: cacheKey) else { - return nil - } - - do { - let decoder = JSONDecoder() - return try decoder.decode(WidgetData.self, from: data) - } catch { - return nil - } + guard let article = NewsWidgetCache.loadTop().randomElement() else { return nil } + return WidgetData( + title: article.title, + timeAgo: timeAgo(from: article.publishedDate), + link: article.link, + publisher: article.publisher + ) } /// Converts a date string to a human-readable time ago format @@ -69,7 +46,7 @@ class NewsService { /// - Returns: Human-readable time difference (e.g. "5 hours ago") func timeAgo(from dateString: String) -> String { let formatter = DateFormatter() - formatter.locale = Locale.current + formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" guard let date = formatter.date(from: dateString) else { @@ -83,60 +60,60 @@ class NewsService { return relativeFormatter.localizedString(for: date, relativeTo: Date()) } + /// Fetches the top 10 most recent articles, persists them to the App Group cache, + /// and triggers a home-screen widget reload. + @discardableResult + func fetchTopArticles() async throws -> [CachedNewsArticle] { + let articles = try await fetchArticles() + let top = articles + .sorted { $0.published > $1.published } + .prefix(10) + .map { article in + CachedNewsArticle( + title: article.title, + publisher: article.publisher.title, + link: article.comments ?? article.link, + publishedDate: article.publishedDate, + publishedEpoch: article.published + ) + } + + NewsWidgetCache.saveTop(top) + NewsHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() + + return top + } + /// Fetches widget data using stale-while-revalidate strategy /// - Parameter returnCachedImmediately: If true, returns cached data immediately if available /// - Returns: Widget data /// - Throws: URLError or decoding error @discardableResult func fetchWidgetData(returnCachedImmediately: Bool = true) async throws -> WidgetData { - // If we want cached data and it exists, return it immediately if returnCachedImmediately, let cachedData = getCachedData() { - // Start fresh fetch in background to update cache (don't await) + // Refresh in background; cache is updated automatically. Task { do { - try await fetchFreshData() - // Cache will be updated automatically in fetchFreshData + try await fetchTopArticles() } catch { - // Silent failure for background updates print("Background news data update failed: \(error)") } } return cachedData } - // No cache available or cache not requested - fetch fresh data - return try await fetchFreshData() - } - - /// Fetches fresh data from API (always hits the network) - @discardableResult - private func fetchFreshData() async throws -> WidgetData { - let articles = try await fetchArticles() - - // Get a random article from the last 10 - let recentArticles = - articles - .sorted { $0.published > $1.published } - .prefix(10) - - guard let article = recentArticles.randomElement() else { + let top = try await fetchTopArticles() + guard let article = top.randomElement() else { Logger.error("No articles available after filtering") throw URLError(.cannotParseResponse) } - let timeAgoString = timeAgo(from: article.publishedDate) - - let widgetData = WidgetData( + return WidgetData( title: article.title, - timeAgo: timeAgoString, - link: article.comments ?? article.link, - publisher: article.publisher.title + timeAgo: timeAgo(from: article.publishedDate), + link: article.link, + publisher: article.publisher ) - - // Cache the data - cacheData(widgetData) - - return widgetData } } @@ -201,7 +178,7 @@ struct Publisher: Codable { let image: String? } -/// Widget data model for caching +/// Widget data model used by the in-app news widget UI. struct WidgetData: Codable { let title: String let timeAgo: String diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index d42550ee2..785346ae2 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -259,6 +259,10 @@ class WidgetsViewModel: ObservableObject { if type == .price, let priceOptions = options as? PriceWidgetOptions { syncPriceOptionsToHomeScreenWidget(priceOptions) } + + if type == .news, let newsOptions = options as? NewsWidgetOptions { + syncNewsOptionsToHomeScreenWidget(newsOptions) + } } catch { print("Failed to save widget options: \(error)") } @@ -324,4 +328,12 @@ class WidgetsViewModel: ObservableObject { PriceHomeScreenWidgetOptionsStore.save(options) PriceHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } + + /// Mirrors in-app news widget options to the App Group so the home-screen WidgetKit widget can read them. + /// Only invoked when the user explicitly changes news widget options — adding, deleting, or resetting + /// in-app widgets must not affect the independent OS home-screen widget. + private func syncNewsOptionsToHomeScreenWidget(_ options: NewsWidgetOptions) { + NewsHomeScreenWidgetOptionsStore.save(options) + NewsHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() + } } diff --git a/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift b/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift new file mode 100644 index 000000000..ad05b9686 --- /dev/null +++ b/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift @@ -0,0 +1,246 @@ +import SwiftUI + +/// Preview screen for the Bitcoin Headlines widget. +struct NewsWidgetPreviewView: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + + @StateObject private var viewModel = NewsViewModel.shared + + @State private var carouselPage: Int = 0 + @State private var showDeleteAlert = false + + private let widgetType: WidgetType = .news + + private var widgetName: String { + t("widgets__news__name") + } + + private var widgetDescription: String { + t("widgets__news__description") + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(widgetType) + } + + private var hasCustomOptions: Bool { + widgets.hasCustomOptions(for: widgetType) + } + + private var currentOptions: NewsWidgetOptions { + widgets.getOptions(for: widgetType, as: NewsWidgetOptions.self) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + NavigationBar(title: widgetName, showMenuButton: false) + + VStack(alignment: .leading, spacing: 0) { + BodyMText(widgetDescription, textColor: .textSecondary) + .padding(.bottom, 16) + + Divider().background(Color.white.opacity(0.1)) + + widgetSettingsRow + + Divider().background(Color.white.opacity(0.1)) + } + + VStack(spacing: 16) { + carousel + + sizeLabel + + pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + buttonsRow + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .task { + viewModel.startUpdates() + } + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteAlert, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } + Button(t("common__delete_yes"), role: .destructive) { onDelete() } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": widgetName])) + } + ) + } + + // MARK: - Widget Settings cell + + private var widgetSettingsRow: some View { + Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { + HStack(alignment: .center, spacing: 0) { + BodyMText(t("widgets__widget__settings"), textColor: .textPrimary) + + Spacer() + + BodyMText( + hasCustomOptions + ? t("widgets__widget__edit_custom") + : t("widgets__widget__edit_default"), + textColor: .textSecondary + ) + + Image("chevron") + .resizable() + .foregroundColor(.textSecondary) + .frame(width: 24, height: 24) + .padding(.leading, 5) + } + .frame(maxWidth: .infinity, minHeight: 51) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("WidgetEdit") + } + + // MARK: - Carousel + + private var carousel: some View { + TabView(selection: $carouselPage) { + compactPage + .tag(0) + + widePage + .tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(maxHeight: .infinity) + } + + private var compactPage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = viewModel.widgetData { + NewsWidgetCompactContent(data: data, options: currentOptions) + } else { + placeholderCompact + } + } + .frame(width: 163, height: 192) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private var widePage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = viewModel.widgetData { + NewsWidgetWideContent(data: data, options: currentOptions) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + placeholderWide + } + } + .frame(maxWidth: .infinity) + Spacer(minLength: 0) + } + } + + private var placeholderCompact: some View { + Color.gray6 + .cornerRadius(16) + .overlay(ProgressView()) + } + + private var placeholderWide: some View { + Color.gray6 + .cornerRadius(16) + .frame(height: 118) + .overlay(ProgressView()) + } + + // MARK: - Size label & page indicator + + private var sizeLabel: some View { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__widget__size_small") + : t("widgets__widget__size_wide"), + textColor: .textSecondary + ) + .textCase(.uppercase) + Spacer() + } + } + + private var pageIndicator: some View { + HStack(spacing: 8) { + Spacer() + ForEach(0 ..< 2, id: \.self) { index in + Circle() + .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) + .frame(width: 8, height: 8) + } + Spacer() + } + } + + // MARK: - Buttons + + private var buttonsRow: some View { + HStack(spacing: 16) { + if isWidgetSaved { + CustomButton( + title: t("common__delete"), + variant: .secondary, + size: .large, + shouldExpand: true + ) { + showDeleteAlert = true + } + .accessibilityIdentifier("WidgetDelete") + } + + CustomButton( + title: t("widgets__widget__save_widget"), + variant: .primary, + size: .large, + shouldExpand: true, + action: onSave + ) + .accessibilityIdentifier("WidgetSave") + } + } + + // MARK: - Actions + + private func onSave() { + widgets.saveWidget(widgetType) + navigation.reset() + } + + private func onDelete() { + widgets.deleteWidget(widgetType) + navigation.reset() + } +} + +#Preview { + NavigationStack { + NewsWidgetPreviewView() + .environmentObject(NavigationViewModel()) + .environmentObject(WidgetsViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index ef037893c..1b95fdc66 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -199,7 +199,7 @@ struct PriceWidgetPreviewView: View { Spacer() ForEach(0 ..< 2, id: \.self) { index in Circle() - .fill(carouselPage == index ? Color.brandAccent : Color.white.opacity(0.32)) + .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) .frame(width: 8, height: 8) } Spacer() diff --git a/Bitkit/Views/Widgets/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index 8aad26a02..1d692fc86 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -46,7 +46,7 @@ struct WidgetEditItemView: View { @ViewBuilder private var accessoryView: some View { switch item.type { - case .toggleItem: + case .staticItem, .toggleItem: Image("check-mark") .resizable() .foregroundColor(item.isChecked ? .brandAccent : .gray3) @@ -61,7 +61,7 @@ struct WidgetEditItemView: View { Color.clear .frame(width: 32, height: 32) } - case .staticItem, .sectionHeader: + case .sectionHeader: EmptyView() } } diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index 1e5478102..1b135d91a 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -139,7 +139,7 @@ class WidgetEditLogic: ObservableObject { case .price: switch item.key { case "BTC/USD", "BTC/EUR", "BTC/GBP", "BTC/JPY": - selectTradingPair(item.key) + priceOptions.selectedPair = item.key case "1D": priceOptions.selectedPeriod = .oneDay case "1W": @@ -157,10 +157,6 @@ class WidgetEditLogic: ObservableObject { onStateChange?() } - private func selectTradingPair(_ pairName: String) { - priceOptions.selectedPair = pairName - } - func loadCurrentOptions() { switch widgetType { case .blocks: diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 9757a5ad9..74852fd92 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -308,17 +308,9 @@ enum WidgetEditItemFactory { ) -> [WidgetEditItem] { var items: [WidgetEditItem] = [] - if let data = newsViewModel.widgetData { - items.append( - WidgetEditItem( - key: "showDate", - type: .toggleItem, - titleView: AnyView(BodyMText(data.timeAgo, textColor: .textPrimary)), - valueView: nil, - isChecked: newsOptions.showDate - ) - ) + items.append(sectionHeaderItem(key: "news_content_header", title: t("widgets__news__content_header"))) + if let data = newsViewModel.widgetData { items.append( WidgetEditItem( key: "showTitle", @@ -333,30 +325,30 @@ enum WidgetEditItemFactory { WidgetEditItem( key: "showSource", type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText(data.publisher, textColor: .textSecondary)), + titleView: AnyView(BodySSBText(data.publisher, textColor: .brandAccent)), + valueView: nil, isChecked: newsOptions.showSource ) ) - } else { - // Fallback when no data is available + items.append( WidgetEditItem( key: "showDate", type: .toggleItem, - titleView: AnyView(BodyMText("13 hours ago", textColor: .textPrimary)), + titleView: AnyView(BodySSBText(data.timeAgo, textColor: .textSecondary)), valueView: nil, isChecked: newsOptions.showDate ) ) - + } else { + // Fallback when no data is available items.append( WidgetEditItem( key: "showTitle", - type: .staticItem, - titleView: AnyView(TitleText("Exodus Launches XO Pay, An In-App Bitcoin And Crypto Purchase Solution")), + type: .toggleItem, + titleView: AnyView(TitleText("How Bitcoin changed El Salvador in more ways...")), valueView: nil, - isChecked: true // Static items are always shown + isChecked: newsOptions.showTitle ) ) @@ -364,11 +356,21 @@ enum WidgetEditItemFactory { WidgetEditItem( key: "showSource", type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("Bitcoin Magazine", textColor: .textSecondary)), + titleView: AnyView(BodySSBText("bitcoinmagazine.com", textColor: .brandAccent)), + valueView: nil, isChecked: newsOptions.showSource ) ) + + items.append( + WidgetEditItem( + key: "showDate", + type: .toggleItem, + titleView: AnyView(BodySSBText("1 min ago", textColor: .textSecondary)), + valueView: nil, + isChecked: newsOptions.showDate + ) + ) } return items diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index 09152b67e..67a5094c2 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -63,14 +63,6 @@ struct WidgetEditView: View { ) .padding(.bottom, 16) - if id != .price { - BodyMText( - t("widgets__widget__edit_description", variables: ["name": widget.name]), - textColor: .textSecondary - ) - .padding(.bottom, 16) - } - ScrollView(showsIndicators: false) { LazyVStack(spacing: 0) { ForEach(getItems(), id: \.key) { item in diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift index 737864ecf..ba9bbdd06 100644 --- a/BitkitWidget/BitkitWidget.swift +++ b/BitkitWidget/BitkitWidget.swift @@ -5,5 +5,6 @@ import WidgetKit struct BitkitWidgetBundle: WidgetBundle { var body: some Widget { BitkitPriceWidget() + BitkitNewsWidget() } } diff --git a/BitkitWidget/NewsHomeScreenWidget.swift b/BitkitWidget/NewsHomeScreenWidget.swift new file mode 100644 index 000000000..4f8887ad5 --- /dev/null +++ b/BitkitWidget/NewsHomeScreenWidget.swift @@ -0,0 +1,277 @@ +import SwiftUI +import WidgetKit + +// MARK: - Entry + +struct NewsWidgetEntry: TimelineEntry { + let date: Date + let article: CachedNewsArticle? + let timeAgo: String + let options: NewsWidgetOptions + /// True when no fresh data could be fetched and there is nothing in cache to fall back to. + let showsError: Bool +} + +// MARK: - Helpers + +private enum NewsWidgetEntryBuilder { + static let refreshInterval: TimeInterval = 15 * 60 + + static func relativeTime(from dateString: String) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" + guard let date = formatter.date(from: dateString) else { return "" } + + let relative = RelativeDateTimeFormatter() + relative.locale = Locale.current + relative.dateTimeStyle = .named + return relative.localizedString(for: date, relativeTo: Date()) + } + + static func currentArticle(from articles: [CachedNewsArticle], at date: Date = Date()) -> CachedNewsArticle? { + guard !articles.isEmpty else { return nil } + let bucket = Int(date.timeIntervalSince1970 / refreshInterval) + let index = abs(bucket) % articles.count + return articles[index] + } +} + +// MARK: - Timeline Provider + +struct NewsWidgetProvider: TimelineProvider { + /// Stable mock for widget gallery / placeholder snapshots. + private static let mockArticle = CachedNewsArticle( + title: "How Bitcoin changed El Salvador in more ways than one", + publisher: "bitcoinmagazine.com", + link: "https://bitcoinmagazine.com", + publishedDate: "Mon, 01 Jan 2024 12:00:00 +0000", + publishedEpoch: 1_704_110_400 + ) + + private static let mockEntry = NewsWidgetEntry( + date: Date(), + article: mockArticle, + timeAgo: "21 min ago", + options: NewsWidgetOptions(), + showsError: false + ) + + func placeholder(in _: Context) -> NewsWidgetEntry { + Self.mockEntry + } + + func getSnapshot(in context: Context, completion: @escaping (NewsWidgetEntry) -> Void) { + let options = NewsHomeScreenWidgetOptionsStore.load() + + if context.isPreview { + completion(NewsWidgetEntry( + date: Self.mockEntry.date, + article: Self.mockArticle, + timeAgo: Self.mockEntry.timeAgo, + options: options, + showsError: false + )) + return + } + + let cached = NewsWidgetService.cachedTopArticles() + let pick = NewsWidgetEntryBuilder.currentArticle(from: cached) + completion(NewsWidgetEntry( + date: Date(), + article: pick, + timeAgo: pick.map { NewsWidgetEntryBuilder.relativeTime(from: $0.publishedDate) } ?? "", + options: options, + showsError: false + )) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let options = NewsHomeScreenWidgetOptionsStore.load() + + Task { + let entry: NewsWidgetEntry + do { + let fresh = try await NewsWidgetService.fetchFreshTopArticles() + if let pick = NewsWidgetEntryBuilder.currentArticle(from: fresh) { + entry = NewsWidgetEntry( + date: Date(), + article: pick, + timeAgo: NewsWidgetEntryBuilder.relativeTime(from: pick.publishedDate), + options: options, + showsError: false + ) + } else { + entry = NewsWidgetEntry(date: Date(), article: nil, timeAgo: "", options: options, showsError: true) + } + } catch { + let cached = NewsWidgetService.cachedTopArticles() + if let pick = NewsWidgetEntryBuilder.currentArticle(from: cached) { + entry = NewsWidgetEntry( + date: Date(), + article: pick, + timeAgo: NewsWidgetEntryBuilder.relativeTime(from: pick.publishedDate), + options: options, + showsError: false + ) + } else { + entry = NewsWidgetEntry(date: Date(), article: nil, timeAgo: "", options: options, showsError: true) + } + } + + let nextRefresh = Date().addingTimeInterval(NewsWidgetEntryBuilder.refreshInterval) + completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + } + } +} + +// MARK: - View + +struct NewsHomeScreenWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode + + var entry: NewsWidgetProvider.Entry + + var body: some View { + Group { + if let url = articleURL { + Link(destination: url) { content } + } else { + content + } + } + .widgetURL(articleURL) + .containerBackground(for: .widget) { backgroundView } + } + + private var articleURL: URL? { + guard let link = entry.article?.link else { return nil } + return URL(string: link) + } + + @ViewBuilder + private var content: some View { + if entry.showsError { + errorView + } else if let article = entry.article { + switch widgetFamily { + case .systemSmall: + compactLayout(article: article) + default: + wideLayout(article: article) + } + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + + // MARK: - Compact (small widget — 163×192) + + private func compactLayout(article: CachedNewsArticle) -> some View { + VStack(alignment: .leading, spacing: 0) { + titleText(article.title) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer(minLength: 8) + + if entry.options.showDate { + HStack { + Spacer(minLength: 0) + Text(entry.timeAgo) + .font(Fonts.semiBold(size: 13)) + .tracking(0.4) + .foregroundColor(secondaryTextColor) + .lineLimit(1) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Wide (medium widget — 343×118) + + private func wideLayout(article: CachedNewsArticle) -> some View { + VStack(alignment: .leading, spacing: 0) { + titleText(article.title) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer(minLength: 8) + + if entry.options.showSource || entry.options.showDate { + HStack(alignment: .center, spacing: 8) { + if entry.options.showSource { + Text(article.publisher) + .font(Fonts.semiBold(size: 13)) + .tracking(0.4) + .foregroundColor(sourceTextColor) + .lineLimit(1) + } + Spacer(minLength: 0) + if entry.options.showDate { + Text(entry.timeAgo) + .font(Fonts.semiBold(size: 13)) + .tracking(0.4) + .foregroundColor(secondaryTextColor) + .lineLimit(1) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Sub-views + + private func titleText(_ value: String) -> some View { + Text(value) + .font(Fonts.bold(size: 22)) + .foregroundColor(titleTextColor) + .lineLimit(4) + .minimumScaleFactor(0.85) + .widgetAccentable() + } + + private var errorView: some View { + Text("Couldn’t load headlines.") + .font(Fonts.regular(size: 13)) + .foregroundColor(secondaryTextColor) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Colors + + private var backgroundView: some View { + widgetRenderingMode == .fullColor ? Color.gray6 : Color.clear + } + + private var titleTextColor: Color { + widgetRenderingMode == .fullColor ? .white : .primary + } + + private var sourceTextColor: Color { + guard widgetRenderingMode == .fullColor else { return .primary } + return .brandAccent + } + + private var secondaryTextColor: Color { + widgetRenderingMode == .fullColor ? .white.opacity(0.64) : .secondary + } +} + +// MARK: - Widget Configuration + +struct BitkitNewsWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration( + kind: NewsHomeScreenWidgetOptionsStore.newsHomeScreenWidgetKind, + provider: NewsWidgetProvider() + ) { entry in + NewsHomeScreenWidgetEntryView(entry: entry) + } + .configurationDisplayName("Bitcoin Headlines") + .description("Latest Bitcoin news headlines, mirroring the in-app headlines widget.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} diff --git a/BitkitWidget/NewsWidgetService.swift b/BitkitWidget/NewsWidgetService.swift new file mode 100644 index 000000000..c5246b05d --- /dev/null +++ b/BitkitWidget/NewsWidgetService.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Slim news fetcher used inside the WidgetKit extension. +/// +/// Reads cached `[CachedNewsArticle]` from the App Group (written by the main app's `NewsService`) +/// and falls back to a direct network fetch when the cache is empty or stale. The cache itself +/// is owned by the main app; this service intentionally does not write back to it. +enum NewsWidgetService { + enum FetchError: Error { + case invalidURL + case noArticlesAvailable + } + + static func cachedTopArticles() -> [CachedNewsArticle] { + NewsWidgetCache.loadTop() + } + + static func fetchFreshTopArticles() async throws -> [CachedNewsArticle] { + guard let url = URL(string: WidgetEnv.newsFeedArticlesUrl) else { throw FetchError.invalidURL } + + let (data, _) = try await URLSession.shared.data(from: url) + let articles = try JSONDecoder().decode([WireArticle].self, from: data) + + let top = articles + .sorted { $0.published > $1.published } + .prefix(10) + .map { wire in + CachedNewsArticle( + title: wire.title, + publisher: wire.publisher.title, + link: wire.comments ?? wire.link, + publishedDate: wire.publishedDate, + publishedEpoch: wire.published + ) + } + + guard !top.isEmpty else { throw FetchError.noArticlesAvailable } + return top + } +} + +// MARK: - Wire Models + +/// Local copy to keep the widget extension's footprint small (mirrors `Article` in main app). +private struct WireArticle: Codable { + let title: String + let published: Int + let publishedDate: String + let link: String + let comments: String? + let publisher: WirePublisher +} + +private struct WirePublisher: Codable { + let title: String + let link: String + let image: String? +} diff --git a/changelog.d/next/546.added.md b/changelog.d/next/546.added.md new file mode 100644 index 000000000..7d9db63b2 --- /dev/null +++ b/changelog.d/next/546.added.md @@ -0,0 +1 @@ +Added a Bitcoin Headlines home-screen widget and redesigned the in-app Headlines widget, preview, and edit screens to match Figma v61. From a2e0e7b770f7205e4c34a71087038577d937e1ec Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 15 May 2026 10:12:22 +0200 Subject: [PATCH 54/60] WIP --- Bitkit/Models/BlocksWidgetOptions.swift | 6 +++--- Bitkit/Views/Widgets/WidgetEditLogic.swift | 23 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Bitkit/Models/BlocksWidgetOptions.swift b/Bitkit/Models/BlocksWidgetOptions.swift index 1054be97e..ccccf077c 100644 --- a/Bitkit/Models/BlocksWidgetOptions.swift +++ b/Bitkit/Models/BlocksWidgetOptions.swift @@ -9,7 +9,7 @@ struct BlocksWidgetOptions: Codable, Equatable { var height: Bool = true var time: Bool = true var date: Bool = true - var transactionCount: Bool = false + var transactionCount: Bool = true var size: Bool = false var fees: Bool = false var showSource: Bool = false @@ -18,7 +18,7 @@ struct BlocksWidgetOptions: Codable, Equatable { height: Bool = true, time: Bool = true, date: Bool = true, - transactionCount: Bool = false, + transactionCount: Bool = true, size: Bool = false, fees: Bool = false, showSource: Bool = false @@ -47,7 +47,7 @@ struct BlocksWidgetOptions: Codable, Equatable { height = try container.decodeIfPresent(Bool.self, forKey: .height) ?? true time = try container.decodeIfPresent(Bool.self, forKey: .time) ?? true date = try container.decodeIfPresent(Bool.self, forKey: .date) ?? true - transactionCount = try container.decodeIfPresent(Bool.self, forKey: .transactionCount) ?? false + transactionCount = try container.decodeIfPresent(Bool.self, forKey: .transactionCount) ?? true size = try container.decodeIfPresent(Bool.self, forKey: .size) ?? false fees = try container.decodeIfPresent(Bool.self, forKey: .fees) ?? false showSource = try container.decodeIfPresent(Bool.self, forKey: .showSource) ?? false diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index 91661143f..ed805b4c6 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -89,18 +89,25 @@ class WidgetEditLogic: ObservableObject { case .blocks: switch item.key { case "height": + guard canToggleBlockOption(blocksOptions.height) else { break } blocksOptions.height.toggle() case "time": + guard canToggleBlockOption(blocksOptions.time) else { break } blocksOptions.time.toggle() case "date": + guard canToggleBlockOption(blocksOptions.date) else { break } blocksOptions.date.toggle() case "transactionCount": + guard canToggleBlockOption(blocksOptions.transactionCount) else { break } blocksOptions.transactionCount.toggle() case "size": + guard canToggleBlockOption(blocksOptions.size) else { break } blocksOptions.size.toggle() case "fees": + guard canToggleBlockOption(blocksOptions.fees) else { break } blocksOptions.fees.toggle() case "showSource": + guard canToggleBlockOption(blocksOptions.showSource) else { break } blocksOptions.showSource.toggle() default: break @@ -161,6 +168,22 @@ class WidgetEditLogic: ObservableObject { priceOptions.selectedPair = pairName } + private func canToggleBlockOption(_ isCurrentlyEnabled: Bool) -> Bool { + isCurrentlyEnabled || enabledBlockOptionsCount < 4 + } + + private var enabledBlockOptionsCount: Int { + [ + blocksOptions.height, + blocksOptions.time, + blocksOptions.date, + blocksOptions.transactionCount, + blocksOptions.size, + blocksOptions.fees, + blocksOptions.showSource, + ].filter { $0 }.count + } + func loadCurrentOptions() { switch widgetType { case .blocks: From f8f4f6f9a43270a6c581824357bad11caad23374 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 15 May 2026 12:34:32 +0200 Subject: [PATCH 55/60] fixes --- .../Views/Widgets/BlocksWidgetPreviewView.swift | 2 +- Bitkit/Views/Widgets/WidgetEditLogic.swift | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift b/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift index 710354853..55796e081 100644 --- a/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift @@ -186,7 +186,7 @@ struct BlocksWidgetPreviewView: View { Spacer() ForEach(0 ..< 2, id: \.self) { index in Circle() - .fill(carouselPage == index ? Color.brandAccent : Color.white.opacity(0.32)) + .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) .frame(width: 8, height: 8) } Spacer() diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index fbbb0db61..410c553f5 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -164,6 +164,22 @@ class WidgetEditLogic: ObservableObject { onStateChange?() } + private func canToggleBlockOption(_ isCurrentlyEnabled: Bool) -> Bool { + isCurrentlyEnabled || enabledBlockOptionsCount < 4 + } + + private var enabledBlockOptionsCount: Int { + [ + blocksOptions.height, + blocksOptions.time, + blocksOptions.date, + blocksOptions.transactionCount, + blocksOptions.size, + blocksOptions.fees, + blocksOptions.showSource, + ].filter { $0 }.count + } + func loadCurrentOptions() { switch widgetType { case .blocks: From 90e68bc14896779e729cf6081d98c66a8f46d8d3 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 15 May 2026 15:18:28 +0200 Subject: [PATCH 56/60] fixes --- Bitkit/Components/Widgets/BlocksWidget.swift | 16 ++------- Bitkit/Models/BlocksWidgetFields.swift | 22 ++----------- Bitkit/Models/BlocksWidgetOptions.swift | 33 +++++++++++++------ .../Localization/en.lproj/Localizable.strings | 4 +-- Bitkit/Services/MigrationsService.swift | 24 ++------------ Bitkit/Utilities/WidgetsBackupConverter.swift | 8 ++--- Bitkit/Views/Widgets/WidgetEditLogic.swift | 5 --- Bitkit/Views/Widgets/WidgetEditModels.swift | 3 +- BitkitWidget/BlocksHomeScreenWidget.swift | 14 +++----- 9 files changed, 41 insertions(+), 88 deletions(-) diff --git a/Bitkit/Components/Widgets/BlocksWidget.swift b/Bitkit/Components/Widgets/BlocksWidget.swift index 94b29626c..3344d0ae5 100644 --- a/Bitkit/Components/Widgets/BlocksWidget.swift +++ b/Bitkit/Components/Widgets/BlocksWidget.swift @@ -1,17 +1,5 @@ import SwiftUI -// MARK: - In-app label override - -/// In-app screens use the localized `widgets__widget__source` value for the Source field; -/// the OS widget uses the hardcoded English `BlocksWidgetField.label` since the widget -/// extension target does not have access to `LocalizeHelpers`. -extension BlocksWidgetField { - var inAppLabel: String { - if self == .showSource { return t("widgets__widget__source") } - return label - } -} - // MARK: - Widget /// In-app Bitcoin Blocks widget (v61). Renders the wide layout — used inside the home feed @@ -86,7 +74,7 @@ private struct BlocksWidgetWideRow: View { .foregroundColor(.brandAccent) .frame(width: 20, height: 20) - BodyMText(field.inAppLabel, textColor: .white80) + BodyMText(field.label, textColor: .white80) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) @@ -105,7 +93,7 @@ struct BlocksWidgetCompactContent: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - ForEach(options.compactFields, id: \.self) { field in + ForEach(options.enabledFields, id: \.self) { field in HStack(alignment: .center, spacing: 8) { Image(field.iconName) .resizable() diff --git a/Bitkit/Models/BlocksWidgetFields.swift b/Bitkit/Models/BlocksWidgetFields.swift index 8c09b0465..c8dfa75b8 100644 --- a/Bitkit/Models/BlocksWidgetFields.swift +++ b/Bitkit/Models/BlocksWidgetFields.swift @@ -1,7 +1,6 @@ import Foundation -/// Ordered field set used by the v61 Blocks widget. Default-selected fields come first so -/// the compact (`.systemSmall`) layout can prioritize them when the row cap kicks in. +/// Ordered field set used by the v61 Blocks widget. /// /// Shared between the main app and the WidgetKit extension via the App Group target membership. /// Labels are intentionally hardcoded English to avoid reaching into the main app's @@ -13,12 +12,6 @@ enum BlocksWidgetField: String, CaseIterable { case transactionCount case size case fees - case showSource - - /// The four fields enabled by default. The compact layout always renders these first when - /// present, then fills any remaining capacity with non-default fields. - static let defaults: [BlocksWidgetField] = [.height, .time, .date, .transactionCount] - static let extras: [BlocksWidgetField] = [.size, .fees, .showSource] var label: String { switch self { @@ -28,7 +21,6 @@ enum BlocksWidgetField: String, CaseIterable { case .transactionCount: return "Transactions" case .size: return "Size" case .fees: return "Fees" - case .showSource: return "Source" } } @@ -41,7 +33,6 @@ enum BlocksWidgetField: String, CaseIterable { case .transactionCount: return "arrow-up-down" case .size: return "file-text" case .fees: return "coins" - case .showSource: return "globe" } } @@ -53,7 +44,6 @@ enum BlocksWidgetField: String, CaseIterable { case .transactionCount: return options.transactionCount case .size: return options.size case .fees: return options.fees - case .showSource: return options.showSource } } @@ -65,21 +55,13 @@ enum BlocksWidgetField: String, CaseIterable { case .transactionCount: return data.transactionCount case .size: return data.size case .fees: return data.fees - case .showSource: return "mempool.space" } } } extension BlocksWidgetOptions { - /// All enabled fields in declared order. Used by the wide / large layouts. + /// All enabled fields in declared order. var enabledFields: [BlocksWidgetField] { BlocksWidgetField.allCases.filter { $0.isEnabled(in: self) } } - - /// Compact layout caps at 4 fields. Defaults come first, extras fill any remaining slots. - var compactFields: [BlocksWidgetField] { - let defaults = BlocksWidgetField.defaults.filter { $0.isEnabled(in: self) } - let extras = BlocksWidgetField.extras.filter { $0.isEnabled(in: self) } - return Array((defaults + extras).prefix(4)) - } } diff --git a/Bitkit/Models/BlocksWidgetOptions.swift b/Bitkit/Models/BlocksWidgetOptions.swift index ccccf077c..0d6838d6d 100644 --- a/Bitkit/Models/BlocksWidgetOptions.swift +++ b/Bitkit/Models/BlocksWidgetOptions.swift @@ -1,10 +1,6 @@ import Foundation /// Options for configuring the in-app and home-screen Bitcoin Blocks widgets (shared via App Group). -/// -/// v61 reduces the field set to seven (Block / Time / Date / Transactions / Size / Fees / Source). -/// The custom decoder silently drops legacy keys (`weight`, `difficulty`, `hash`, `merkleRoot`) and -/// fills in defaults for any keys missing from older persisted blobs. struct BlocksWidgetOptions: Codable, Equatable { var height: Bool = true var time: Bool = true @@ -12,7 +8,6 @@ struct BlocksWidgetOptions: Codable, Equatable { var transactionCount: Bool = true var size: Bool = false var fees: Bool = false - var showSource: Bool = false init( height: Bool = true, @@ -20,8 +15,7 @@ struct BlocksWidgetOptions: Codable, Equatable { date: Bool = true, transactionCount: Bool = true, size: Bool = false, - fees: Bool = false, - showSource: Bool = false + fees: Bool = false ) { self.height = height self.time = time @@ -29,7 +23,7 @@ struct BlocksWidgetOptions: Codable, Equatable { self.transactionCount = transactionCount self.size = size self.fees = fees - self.showSource = showSource + limitEnabledFields() } private enum CodingKeys: String, CodingKey { @@ -39,7 +33,6 @@ struct BlocksWidgetOptions: Codable, Equatable { case transactionCount case size case fees - case showSource } init(from decoder: Decoder) throws { @@ -50,6 +43,26 @@ struct BlocksWidgetOptions: Codable, Equatable { transactionCount = try container.decodeIfPresent(Bool.self, forKey: .transactionCount) ?? true size = try container.decodeIfPresent(Bool.self, forKey: .size) ?? false fees = try container.decodeIfPresent(Bool.self, forKey: .fees) ?? false - showSource = try container.decodeIfPresent(Bool.self, forKey: .showSource) ?? false + limitEnabledFields() + } + + private mutating func limitEnabledFields() { + let fields: [WritableKeyPath] = [ + \.height, + \.time, + \.date, + \.transactionCount, + \.size, + \.fees, + ] + + var enabledCount = 0 + for field in fields where self[keyPath: field] { + if enabledCount < 4 { + enabledCount += 1 + } else { + self[keyPath: field] = false + } + } } } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index eecf264a2..a74556768 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1405,9 +1405,9 @@ "widgets__news__error" = "Couldn\'t get the latest news"; "widgets__news__content_header" = "Content"; "widgets__blocks__name" = "Bitcoin Blocks"; -"widgets__blocks__description" = "Examine various statistics on newly mined Bitcoin Blocks."; +"widgets__blocks__description" = "Examine various statistics on newly mined Bitcoin Blocks. Powered by mempool.space."; "widgets__blocks__error" = "Couldn\'t get blocks data"; -"widgets__blocks__data_header" = "Data"; +"widgets__blocks__data_header" = "Data (max 4)"; "widgets__facts__name" = "Bitcoin Facts"; "widgets__facts__description" = "Discover fun facts about Bitcoin, every time you open your wallet."; "widgets__calculator__name" = "Bitcoin Calculator"; diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index b3d3c5f3a..43a0a8370 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -270,19 +270,6 @@ private struct MigrationNewsWidgetOptions: Codable { var showSource: Bool } -private struct MigrationBlocksWidgetOptions: Codable { - var height: Bool - var time: Bool - var date: Bool - var transactionCount: Bool - var size: Bool - var weight: Bool - var difficulty: Bool - var hash: Bool - var merkleRoot: Bool - var showSource: Bool -} - private struct MigrationFactsWidgetOptions: Codable { var showSource: Bool } @@ -1962,17 +1949,12 @@ extension MigrationsService { let blocksPrefs = (widgetsDict["blocksPreferences"] as? [String: Any]) ?? (widgetsDict["blocks"] as? [String: Any]) if let prefs = blocksPrefs { - let options = MigrationBlocksWidgetOptions( + let options = BlocksWidgetOptions( height: getBool(from: prefs, key: "height", fallbackKey: "showBlock", defaultValue: true), time: getBool(from: prefs, key: "time", fallbackKey: "showTime", defaultValue: true), date: getBool(from: prefs, key: "date", fallbackKey: "showDate", defaultValue: true), - transactionCount: getBool(from: prefs, key: "transactionCount", fallbackKey: "showTransactions", defaultValue: false), - size: getBool(from: prefs, key: "size", fallbackKey: "showSize", defaultValue: false), - weight: getBool(from: prefs, key: "weight", defaultValue: false), - difficulty: getBool(from: prefs, key: "difficulty", defaultValue: false), - hash: getBool(from: prefs, key: "hash", defaultValue: false), - merkleRoot: getBool(from: prefs, key: "merkleRoot", defaultValue: false), - showSource: getBool(from: prefs, key: "showSource", defaultValue: false) + transactionCount: getBool(from: prefs, key: "transactionCount", fallbackKey: "showTransactions", defaultValue: true), + size: getBool(from: prefs, key: "size", fallbackKey: "showSize", defaultValue: false) ) if let data = try? JSONEncoder().encode(options) { result["blocks"] = data diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 32fe891da..7783d31f8 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -32,7 +32,7 @@ enum WidgetsBackupConverter { "showDate": options.date, "showTransactions": options.transactionCount, "showSize": options.size, - "showSource": options.showSource, + "showFees": options.fees, ] } case .news: @@ -125,10 +125,9 @@ enum WidgetsBackupConverter { height: prefs["showBlock"] as? Bool ?? true, time: prefs["showTime"] as? Bool ?? true, date: prefs["showDate"] as? Bool ?? true, - transactionCount: prefs["showTransactions"] as? Bool ?? false, + transactionCount: prefs["showTransactions"] as? Bool ?? true, size: prefs["showSize"] as? Bool ?? false, - fees: prefs["showFees"] as? Bool ?? false, - showSource: prefs["showSource"] as? Bool ?? false + fees: prefs["showFees"] as? Bool ?? false ) optionsData = try? JSONEncoder().encode(iosOptions) } @@ -199,7 +198,6 @@ enum WidgetsBackupConverter { "showTransactions": defaults.transactionCount, "showSize": defaults.size, "showFees": defaults.fees, - "showSource": defaults.showSource, ] } diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index 410c553f5..b530ff4f0 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -40,7 +40,6 @@ class WidgetEditLogic: ObservableObject { || blocksOptions.transactionCount || blocksOptions.size || blocksOptions.fees - || blocksOptions.showSource case .news: return newsOptions.showTitle || newsOptions.showSource || newsOptions.showDate case .facts: @@ -106,9 +105,6 @@ class WidgetEditLogic: ObservableObject { case "fees": guard canToggleBlockOption(blocksOptions.fees) else { break } blocksOptions.fees.toggle() - case "showSource": - guard canToggleBlockOption(blocksOptions.showSource) else { break } - blocksOptions.showSource.toggle() default: break } @@ -176,7 +172,6 @@ class WidgetEditLogic: ObservableObject { blocksOptions.transactionCount, blocksOptions.size, blocksOptions.fees, - blocksOptions.showSource, ].filter { $0 }.count } diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 65e664c6a..5a347f9c1 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -82,7 +82,6 @@ enum WidgetEditItemFactory { for field in BlocksWidgetField.allCases { let value: String = { - if field == .showSource { return "mempool.space" } if let data = blocksViewModel.blockData { return field.value(from: data) } return fallback[field] ?? "" }() @@ -94,7 +93,7 @@ enum WidgetEditItemFactory { .renderingMode(.template) .foregroundColor(.brandAccent) .frame(width: 20, height: 20) - BodySSBText(field.inAppLabel, textColor: .textSecondary) + BodySSBText(field.label, textColor: .textSecondary) } ) items.append( diff --git a/BitkitWidget/BlocksHomeScreenWidget.swift b/BitkitWidget/BlocksHomeScreenWidget.swift index 17ab544da..6f3c348a9 100644 --- a/BitkitWidget/BlocksHomeScreenWidget.swift +++ b/BitkitWidget/BlocksHomeScreenWidget.swift @@ -107,7 +107,7 @@ struct BlocksHomeScreenWidgetEntryView: View { case .systemSmall: compactLayout(block: block) default: - wideLayout(block: block, fields: entry.options.compactFields) + wideLayout(block: block, fields: entry.options.enabledFields) } } else { ProgressView() @@ -117,11 +117,10 @@ struct BlocksHomeScreenWidgetEntryView: View { // MARK: - Layouts - /// Compact (`.systemSmall`): icon + value rows, capped at 4. Default-selected fields - /// take priority; remaining slots are filled by extras in declared order. + /// Compact (`.systemSmall`): icon + value rows for the selected fields. private func compactLayout(block: CachedBlock) -> some View { VStack(alignment: .leading, spacing: 16) { - ForEach(entry.options.compactFields, id: \.self) { field in + ForEach(entry.options.enabledFields, id: \.self) { field in HStack(alignment: .center, spacing: 8) { iconImage(field: field) Text(field.value(from: block)) @@ -132,15 +131,13 @@ struct BlocksHomeScreenWidgetEntryView: View { .widgetAccentable() } } - Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - /// Wide layout (`.systemMedium`): icon + label + value rows, capped at 4 fields with the - /// same default-priority logic as the small widget. + /// Wide layout (`.systemMedium`): icon + label + value rows for the selected fields. private func wideLayout(block: CachedBlock, fields: [BlocksWidgetField]) -> some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 16) { ForEach(fields, id: \.self) { field in HStack(alignment: .center, spacing: 8) { iconImage(field: field) @@ -157,7 +154,6 @@ struct BlocksHomeScreenWidgetEntryView: View { .widgetAccentable() } } - Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } From 33431a026a727c45e6055b0a05aaaa750ec46178 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 15 May 2026 16:02:58 +0200 Subject: [PATCH 57/60] fixes --- Bitkit.xcodeproj/project.pbxproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 6c3746859..703b0c05a 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -108,6 +108,7 @@ "Extensions/LDKNode+AddressType.swift", Extensions/PaymentDetails.swift, Models/BlocktankNotificationType.swift, + Models/BlocksWidgetOptions.swift, Models/LnPeer.swift, Models/PubkyPublicKeyFormat.swift, Models/Toast.swift, From e3af5054048832065ad3564432e158e7121e6b5b Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 15 May 2026 21:22:16 +0200 Subject: [PATCH 58/60] feat: add bitcoin facts widgets --- Bitkit.xcodeproj/project.pbxproj | 52 +++++- .../icons/bitcoin.imageset}/Contents.json | 2 +- .../icons/bitcoin.imageset/bitcoin.pdf | Bin 0 -> 6147 bytes Bitkit/Components/Widgets/FactsWidget.swift | 87 +++++----- Bitkit/MainNavView.swift | 2 + .../BitcoinFacts.swift} | 31 +--- Bitkit/Services/MigrationsService.swift | 15 -- Bitkit/Styles/Colors.swift | 1 + Bitkit/Utilities/WidgetsBackupConverter.swift | 26 +-- .../ViewModels/Widgets/FactsViewModel.swift | 11 +- Bitkit/ViewModels/WidgetsViewModel.swift | 16 +- .../Widgets/FactsWidgetPreviewView.swift | 161 ++++++++++++++++++ Bitkit/Views/Widgets/WidgetDetailView.swift | 4 +- Bitkit/Views/Widgets/WidgetEditLogic.swift | 36 +--- Bitkit/Views/Widgets/WidgetEditModels.swift | 32 +--- Bitkit/Views/Widgets/WidgetEditView.swift | 3 - .../bitcoin.imageset/Contents.json | 15 ++ .../bitcoin.imageset/bitcoin.pdf | Bin 0 -> 6031 bytes .../Assets.xcassets/btc.imageset/btc.pdf | Bin 11533 -> 0 bytes BitkitWidget/BitkitWidget.swift | 1 + BitkitWidget/FactsHomeScreenWidget.swift | 141 +++++++++++++++ 21 files changed, 442 insertions(+), 194 deletions(-) rename {BitkitWidget/Assets.xcassets/btc.imageset => Bitkit/Assets.xcassets/icons/bitcoin.imageset}/Contents.json (78%) create mode 100644 Bitkit/Assets.xcassets/icons/bitcoin.imageset/bitcoin.pdf rename Bitkit/{Services/Widgets/FactsService.swift => Models/BitcoinFacts.swift} (81%) create mode 100644 Bitkit/Views/Widgets/FactsWidgetPreviewView.swift create mode 100644 BitkitWidget/Assets.xcassets/bitcoin.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/bitcoin.imageset/bitcoin.pdf delete mode 100644 BitkitWidget/Assets.xcassets/btc.imageset/btc.pdf create mode 100644 BitkitWidget/FactsHomeScreenWidget.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 703b0c05a..e7c80f00c 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 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, ); }; }; + 4A319B702E8F2600002B9AC9 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4A319B712E8F2600002B9AC9 /* Localizable.strings */; }; 4AAB08CA2E1FE77600BA63DF /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4AAB08C92E1FE77600BA63DF /* Lottie */; }; 961058E32C355B5500E1F1D8 /* BitkitNotification.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 9613018B2C5022D700878183 /* LDKNode */; }; @@ -79,6 +80,21 @@ 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 = ""; }; + 4A319B722E8F2600002B9AC9 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = Bitkit/Resources/Localization/ar.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B732E8F2600002B9AC9 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = Bitkit/Resources/Localization/ca.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B742E8F2600002B9AC9 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = Bitkit/Resources/Localization/cs.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B752E8F2600002B9AC9 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = Bitkit/Resources/Localization/de.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B762E8F2600002B9AC9 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = Bitkit/Resources/Localization/el.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B772E8F2600002B9AC9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Bitkit/Resources/Localization/en.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B782E8F2600002B9AC9 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "Bitkit/Resources/Localization/es-419.lproj/Localizable.strings"; sourceTree = SOURCE_ROOT; }; + 4A319B792E8F2600002B9AC9 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = Bitkit/Resources/Localization/es.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B7A2E8F2600002B9AC9 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Bitkit/Resources/Localization/fr.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B7B2E8F2600002B9AC9 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = Bitkit/Resources/Localization/it.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B7C2E8F2600002B9AC9 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = Bitkit/Resources/Localization/nl.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B7D2E8F2600002B9AC9 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = Bitkit/Resources/Localization/pl.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B7E2E8F2600002B9AC9 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings"; sourceTree = SOURCE_ROOT; }; + 4A319B7F2E8F2600002B9AC9 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = Bitkit/Resources/Localization/pt.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B802E8F2600002B9AC9 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = Bitkit/Resources/Localization/ru.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; 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; }; @@ -177,6 +193,7 @@ Models/BlocksWidgetData.swift, Models/BlocksWidgetFields.swift, Models/BlocksWidgetOptions.swift, + Models/BitcoinFacts.swift, Models/NewsWidgetData.swift, Models/NewsWidgetOptions.swift, Models/PriceWidgetData.swift, @@ -274,6 +291,7 @@ 96A44F562CEF5F5400FBACFF /* BitkitUITests */, 96A44F5C2CEF5F5800FBACFF /* BitkitNotification */, 4A319B542E8F24F2002B9AC9 /* BitkitWidget */, + 4A319B712E8F2600002B9AC9 /* Localizable.strings */, 96FE1F622C2DE6AA006D0C8B /* Products */, 961058EC2C35798C00E1F1D8 /* Frameworks */, ); @@ -496,6 +514,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4A319B702E8F2600002B9AC9 /* Localizable.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -529,6 +548,31 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXVariantGroup section */ + 4A319B712E8F2600002B9AC9 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 4A319B722E8F2600002B9AC9 /* ar */, + 4A319B732E8F2600002B9AC9 /* ca */, + 4A319B742E8F2600002B9AC9 /* cs */, + 4A319B752E8F2600002B9AC9 /* de */, + 4A319B762E8F2600002B9AC9 /* el */, + 4A319B772E8F2600002B9AC9 /* en */, + 4A319B782E8F2600002B9AC9 /* es-419 */, + 4A319B792E8F2600002B9AC9 /* es */, + 4A319B7A2E8F2600002B9AC9 /* fr */, + 4A319B7B2E8F2600002B9AC9 /* it */, + 4A319B7C2E8F2600002B9AC9 /* nl */, + 4A319B7D2E8F2600002B9AC9 /* pl */, + 4A319B7E2E8F2600002B9AC9 /* pt-BR */, + 4A319B7F2E8F2600002B9AC9 /* pt */, + 4A319B802E8F2600002B9AC9 /* ru */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + /* Begin PBXShellScriptBuildPhase section */ 96EMBED0012026012000FRAME /* Remove Static Framework Stubs */ = { isa = PBXShellScriptBuildPhase; @@ -621,7 +665,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 186; + CURRENT_PROJECT_VERSION = 187; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitWidget/Info.plist; @@ -633,7 +677,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.0; + MARKETING_VERSION = 2.2.1; PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit.widget"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -654,7 +698,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 186; + CURRENT_PROJECT_VERSION = 187; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitWidget/Info.plist; @@ -666,7 +710,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.0; + MARKETING_VERSION = 2.2.1; PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit.widget"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/BitkitWidget/Assets.xcassets/btc.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/bitcoin.imageset/Contents.json similarity index 78% rename from BitkitWidget/Assets.xcassets/btc.imageset/Contents.json rename to Bitkit/Assets.xcassets/icons/bitcoin.imageset/Contents.json index 50f875c92..8a6583348 100644 --- a/BitkitWidget/Assets.xcassets/btc.imageset/Contents.json +++ b/Bitkit/Assets.xcassets/icons/bitcoin.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "btc.pdf", + "filename" : "bitcoin.pdf", "idiom" : "universal" } ], diff --git a/Bitkit/Assets.xcassets/icons/bitcoin.imageset/bitcoin.pdf b/Bitkit/Assets.xcassets/icons/bitcoin.imageset/bitcoin.pdf new file mode 100644 index 0000000000000000000000000000000000000000..caaae04e51b6553e476e406da137a35ee3670892 GIT binary patch literal 6147 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 PrVND&?Axb*%;0|jDIlA0 literal 0 HcmV?d00001 diff --git a/Bitkit/Components/Widgets/FactsWidget.swift b/Bitkit/Components/Widgets/FactsWidget.swift index 2944c35f1..b4f43db9f 100644 --- a/Bitkit/Components/Widgets/FactsWidget.swift +++ b/Bitkit/Components/Widgets/FactsWidget.swift @@ -1,45 +1,17 @@ import SwiftUI -/// Options for configuring the FactsWidget -struct FactsWidgetOptions: Codable, Equatable { - var showSource: Bool = true -} - struct FactsWidget: View { - /// Configuration options for the widget - var options: FactsWidgetOptions = .init() - - /// Flag indicating if the widget is in editing mode var isEditing: Bool = false - - /// Callback to signal when editing should end var onEditingEnd: (() -> Void)? - /// View model for handling facts data @StateObject private var viewModel = FactsViewModel.shared - /// Initialize the widget - init( - options: FactsWidgetOptions = FactsWidgetOptions(), - isEditing: Bool = false, - onEditingEnd: (() -> Void)? = nil - ) { - self.options = options - self.isEditing = isEditing - self.onEditingEnd = onEditingEnd - } - - /// Initialize with a custom view model (for previews) init( - viewModel: FactsViewModel, - options: FactsWidgetOptions = FactsWidgetOptions(), isEditing: Bool = false, onEditingEnd: (() -> Void)? = nil ) { - self.options = options self.isEditing = isEditing self.onEditingEnd = onEditingEnd - _viewModel = StateObject(wrappedValue: viewModel) } var body: some View { @@ -48,30 +20,57 @@ struct FactsWidget: View { isEditing: isEditing, onEditingEnd: onEditingEnd ) { - VStack(spacing: 0) { - TitleText(viewModel.fact) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) + FactsWidgetWideContent(fact: viewModel.fact) + } + } +} - if options.showSource { - WidgetContentBuilder.sourceRow(source: "synonym.to") - } - } +struct FactsWidgetWideContent: View { + let fact: String + + var body: some View { + HStack(alignment: .top, spacing: 32) { + TitleText(fact) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .leading) + + BitcoinLogo() } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct FactsWidgetCompactContent: View { + let fact: String + + var body: some View { + BodyMSBText(fact) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .leading) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .overlay(alignment: .bottomTrailing) { + BitcoinLogo() + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.gray6) + .cornerRadius(16) + } +} + +private struct BitcoinLogo: View { + var body: some View { + Image("bitcoin") + .resizable() + .frame(width: 32, height: 32) } } #Preview { VStack(spacing: 16) { FactsWidget() - - FactsWidget( - options: FactsWidgetOptions(showSource: false) - ) - - FactsWidget( - isEditing: true - ) + FactsWidget(isEditing: true) } .padding() .background(Color.black) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 136ee5ff1..023305007 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -445,6 +445,8 @@ struct MainNavView: View { NewsWidgetPreviewView() case .blocks: BlocksWidgetPreviewView() + case .facts: + FactsWidgetPreviewView() default: WidgetDetailView(id: widgetType) } diff --git a/Bitkit/Services/Widgets/FactsService.swift b/Bitkit/Models/BitcoinFacts.swift similarity index 81% rename from Bitkit/Services/Widgets/FactsService.swift rename to Bitkit/Models/BitcoinFacts.swift index 5ccfddca3..48d5fca8c 100644 --- a/Bitkit/Services/Widgets/FactsService.swift +++ b/Bitkit/Models/BitcoinFacts.swift @@ -1,26 +1,7 @@ import Foundation -/// Service for managing Bitcoin facts -class FactsService { - static let shared = FactsService() - - private init() {} - - /// Returns a random Bitcoin fact - /// - Returns: A Bitcoin fact string - func getRandomFact() -> String { - return facts.randomElement()! - } - - /// Returns all available Bitcoin facts - /// - Returns: Array of Bitcoin facts - func getAllFacts() -> [String] { - return facts - } - - // MARK: - Private Properties - - private let facts = [ +enum BitcoinFacts { + static let all = [ "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.", @@ -36,7 +17,7 @@ class FactsService { "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' 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.", @@ -48,10 +29,10 @@ class FactsService { "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. ", + "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.”", + "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.", @@ -84,7 +65,7 @@ class FactsService { "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.", + "More computing power != more bitcoin.", "Bitcoin doesn't need your personal info.", "Satoshi considered calling it Netcoin.", ] diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 43a0a8370..c3a2dd786 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -270,10 +270,6 @@ private struct MigrationNewsWidgetOptions: Codable { var showSource: Bool } -private struct MigrationFactsWidgetOptions: Codable { - var showSource: Bool -} - // MARK: - RN Migration Keys enum RNKeychainKey { @@ -1961,17 +1957,6 @@ extension MigrationsService { } } - let factsPrefs = (widgetsDict["factsPreferences"] as? [String: Any]) - ?? (widgetsDict["facts"] as? [String: Any]) - if let prefs = factsPrefs { - let options = MigrationFactsWidgetOptions( - showSource: getBool(from: prefs, key: "showSource", defaultValue: false) - ) - if let data = try? JSONEncoder().encode(options) { - result["facts"] = data - } - } - return result } } diff --git a/Bitkit/Styles/Colors.swift b/Bitkit/Styles/Colors.swift index e3b0533b0..389087b76 100644 --- a/Bitkit/Styles/Colors.swift +++ b/Bitkit/Styles/Colors.swift @@ -10,6 +10,7 @@ extension Color { static let redAccent = Color(hex: 0xE95164) static let yellowAccent = Color(hex: 0xFFD200) static let pubkyGreen = Color(hex: 0xBEFF00) + static let bitcoin = Color(hex: 0xF7931A) // MARK: - Base diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 7783d31f8..706e6660c 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -7,7 +7,6 @@ enum WidgetsBackupConverter { var widgetsArray: [[String: Any]] = [] var blocksPreferences: [String: Any]? var newsPreferences: [String: Any]? - var factsPreferences: [String: Any]? var weatherPreferences: [String: Any]? var pricePreferences: [String: Any]? @@ -42,12 +41,6 @@ enum WidgetsBackupConverter { "showSource": options.showSource, ] } - case .facts: - if let options = try? JSONDecoder().decode(FactsWidgetOptions.self, from: optionsData) { - factsPreferences = [ - "showSource": options.showSource, - ] - } case .weather: if let options = try? JSONDecoder().decode(WeatherWidgetOptions.self, from: optionsData) { weatherPreferences = [ @@ -66,7 +59,7 @@ enum WidgetsBackupConverter { "period": androidPeriod, ] } - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: break } } @@ -75,7 +68,6 @@ enum WidgetsBackupConverter { return [ "widgets": widgetsArray, "headlinePreferences": newsPreferences ?? getDefaultNewsPreferences(), - "factsPreferences": factsPreferences ?? getDefaultFactsPreferences(), "blocksPreferences": blocksPreferences ?? getDefaultBlocksPreferences(), "weatherPreferences": weatherPreferences ?? getDefaultWeatherPreferences(), "pricePreferences": pricePreferences ?? getDefaultPricePreferences(), @@ -140,13 +132,6 @@ enum WidgetsBackupConverter { ) optionsData = try? JSONEncoder().encode(iosOptions) } - case .facts: - if let prefs = jsonDict["factsPreferences"] as? [String: Any] { - let iosOptions = FactsWidgetOptions( - showSource: prefs["showSource"] as? Bool ?? false - ) - optionsData = try? JSONEncoder().encode(iosOptions) - } case .weather: if let prefs = jsonDict["weatherPreferences"] as? [String: Any] { let iosOptions = WeatherWidgetOptions( @@ -176,7 +161,7 @@ enum WidgetsBackupConverter { ) optionsData = try? JSONEncoder().encode(iosOptions) } - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: break } @@ -209,13 +194,6 @@ enum WidgetsBackupConverter { ] } - private static func getDefaultFactsPreferences() -> [String: Any] { - let defaults = FactsWidgetOptions() - return [ - "showSource": defaults.showSource, - ] - } - private static func getDefaultWeatherPreferences() -> [String: Any] { let defaults = WeatherWidgetOptions() return [ diff --git a/Bitkit/ViewModels/Widgets/FactsViewModel.swift b/Bitkit/ViewModels/Widgets/FactsViewModel.swift index dbf10fb2e..4852746e8 100644 --- a/Bitkit/ViewModels/Widgets/FactsViewModel.swift +++ b/Bitkit/ViewModels/Widgets/FactsViewModel.swift @@ -7,19 +7,18 @@ class FactsViewModel: ObservableObject { @Published var fact: String = "" - private let factsService = FactsService.shared private var refreshTimer: Timer? private let refreshInterval: TimeInterval = 2 * 60 // 2 minutes /// Private initializer for the singleton instance private init() { - fact = factsService.getRandomFact() + fact = randomFact() startRefreshTimer() } /// Public initializer for previews and testing init(preview: Bool = true) { - fact = factsService.getRandomFact() + fact = randomFact() } deinit { @@ -29,8 +28,12 @@ class FactsViewModel: ObservableObject { private func startRefreshTimer() { refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in - self?.fact = self?.factsService.getRandomFact() ?? "" + self?.fact = self?.randomFact() ?? "" } } } + + private func randomFact() -> String { + BitcoinFacts.all.randomElement()! + } } diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 73020328f..31b3929c0 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -12,12 +12,10 @@ protocol WidgetOptionsProtocol: Codable, Equatable { /// Default options for each widget type func getDefaultOptions(for type: WidgetType) -> Any { switch type { - case .suggestions, .calculator: + case .suggestions, .calculator, .facts: return EmptyWidgetOptions() case .blocks: return BlocksWidgetOptions() - case .facts: - return FactsWidgetOptions() case .news: return NewsWidgetOptions() case .weather: @@ -74,11 +72,7 @@ struct Widget: Identifiable { case .calculator: CalculatorWidget(isEditing: isEditing, onEditingEnd: onEditingEnd) case .facts: - FactsWidget( - options: widgetsViewModel.getOptions(for: type, as: FactsWidgetOptions.self), - isEditing: isEditing, - onEditingEnd: onEditingEnd - ) + FactsWidget(isEditing: isEditing, onEditingEnd: onEditingEnd) case .news: NewsWidget( options: widgetsViewModel.getOptions(for: type, as: NewsWidgetOptions.self), @@ -275,16 +269,12 @@ class WidgetsViewModel: ObservableObject { /// Check if widget has custom options (different from default) func hasCustomOptions(for type: WidgetType) -> Bool { switch type { - case .suggestions, .calculator: + case .suggestions, .calculator, .facts: return false case .blocks: let current: BlocksWidgetOptions = getOptions(for: type, as: BlocksWidgetOptions.self) let defaultOptions = BlocksWidgetOptions() return current != defaultOptions - case .facts: - let current: FactsWidgetOptions = getOptions(for: type, as: FactsWidgetOptions.self) - let defaultOptions = FactsWidgetOptions() - return current != defaultOptions case .news: let current: NewsWidgetOptions = getOptions(for: type, as: NewsWidgetOptions.self) let defaultOptions = NewsWidgetOptions() diff --git a/Bitkit/Views/Widgets/FactsWidgetPreviewView.swift b/Bitkit/Views/Widgets/FactsWidgetPreviewView.swift new file mode 100644 index 000000000..109d3c45f --- /dev/null +++ b/Bitkit/Views/Widgets/FactsWidgetPreviewView.swift @@ -0,0 +1,161 @@ +import SwiftUI + +/// Preview screen for the Bitcoin Facts widget. +struct FactsWidgetPreviewView: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + + @StateObject private var viewModel = FactsViewModel.shared + + @State private var carouselPage: Int = 0 + @State private var showDeleteAlert = false + + private let widgetType: WidgetType = .facts + + private var widgetName: String { + t("widgets__facts__name") + } + + private var widgetDescription: String { + t("widgets__facts__description") + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(widgetType) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + NavigationBar(title: widgetName, showMenuButton: false) + + BodyMText(widgetDescription, textColor: .textSecondary) + + VStack(spacing: 16) { + carousel + + sizeLabel + + pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + buttonsRow + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteAlert, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } + Button(t("common__delete_yes"), role: .destructive) { onDelete() } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": widgetName])) + } + ) + } + + private var carousel: some View { + TabView(selection: $carouselPage) { + compactPage.tag(0) + widePage.tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(maxHeight: .infinity) + } + + private var compactPage: some View { + VStack { + Spacer(minLength: 0) + FactsWidgetCompactContent(fact: viewModel.fact) + .frame(width: 163, height: 192) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private var widePage: some View { + VStack { + Spacer(minLength: 0) + FactsWidgetWideContent(fact: viewModel.fact) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + .frame(maxWidth: .infinity) + Spacer(minLength: 0) + } + } + + private var sizeLabel: some View { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__widget__size_small") + : t("widgets__widget__size_wide"), + textColor: .textSecondary + ) + .textCase(.uppercase) + Spacer() + } + } + + private var pageIndicator: some View { + HStack(spacing: 8) { + Spacer() + ForEach(0 ..< 2, id: \.self) { index in + Circle() + .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) + .frame(width: 8, height: 8) + } + Spacer() + } + } + + private var buttonsRow: some View { + HStack(spacing: 16) { + if isWidgetSaved { + CustomButton( + title: t("common__delete"), + variant: .secondary, + size: .large, + shouldExpand: true + ) { + showDeleteAlert = true + } + .accessibilityIdentifier("WidgetDelete") + } + + CustomButton( + title: t("widgets__widget__save_widget"), + variant: .primary, + size: .large, + shouldExpand: true, + action: onSave + ) + .accessibilityIdentifier("WidgetSave") + } + } + + private func onSave() { + widgets.saveWidget(widgetType) + navigation.reset() + } + + private func onDelete() { + widgets.deleteWidget(widgetType) + navigation.reset() + } +} + +#Preview { + NavigationStack { + FactsWidgetPreviewView() + .environmentObject(NavigationViewModel()) + .environmentObject(WidgetsViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Widgets/WidgetDetailView.swift b/Bitkit/Views/Widgets/WidgetDetailView.swift index 7bab64f76..362e5f2f4 100644 --- a/Bitkit/Views/Widgets/WidgetDetailView.swift +++ b/Bitkit/Views/Widgets/WidgetDetailView.swift @@ -30,9 +30,9 @@ struct WidgetDetailView: View { /// Check if widget has customization options private var hasOptions: Bool { switch id { - case .blocks, .facts, .news, .price, .weather: + case .blocks, .news, .price, .weather: return true - case .suggestions, .calculator: + case .suggestions, .calculator, .facts: return false } } diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index b530ff4f0..7e50e2f14 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -5,7 +5,6 @@ import SwiftUI @MainActor class WidgetEditLogic: ObservableObject { @Published var blocksOptions = BlocksWidgetOptions() - @Published var factsOptions = FactsWidgetOptions() @Published var newsOptions = NewsWidgetOptions() @Published var weatherOptions = WeatherWidgetOptions() @Published var priceOptions = PriceWidgetOptions() @@ -24,9 +23,9 @@ class WidgetEditLogic: ObservableObject { var hasOptions: Bool { switch widgetType { - case .facts, .blocks, .news, .price, .weather: + case .blocks, .news, .price, .weather: return true - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: return false } } @@ -42,16 +41,13 @@ class WidgetEditLogic: ObservableObject { || blocksOptions.fees case .news: return newsOptions.showTitle || newsOptions.showSource || newsOptions.showDate - case .facts: - // Facts widget's static title is always shown, so it always has an enabled option - return true case .weather: // Weather widget has multiple options, check if any are enabled return weatherOptions.showStatus || weatherOptions.showText || weatherOptions.showMedian || weatherOptions.showNextBlockFee case .price: // Price widget always has a selected pair (single-select). return true - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: return false } } @@ -61,9 +57,6 @@ class WidgetEditLogic: ObservableObject { case .blocks: let defaultOptions = BlocksWidgetOptions() return blocksOptions != defaultOptions - case .facts: - let defaultOptions = FactsWidgetOptions() - return factsOptions != defaultOptions case .news: let defaultOptions = NewsWidgetOptions() return newsOptions != defaultOptions @@ -73,7 +66,7 @@ class WidgetEditLogic: ObservableObject { case .price: let defaultOptions = PriceWidgetOptions() return priceOptions != defaultOptions - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: return false } } @@ -108,13 +101,6 @@ class WidgetEditLogic: ObservableObject { default: break } - case .facts: - switch item.key { - case "showSource": - factsOptions.showSource.toggle() - default: - break - } case .news: switch item.key { case "showDate": @@ -154,7 +140,7 @@ class WidgetEditLogic: ObservableObject { default: break } - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: break } onStateChange?() @@ -179,15 +165,13 @@ class WidgetEditLogic: ObservableObject { switch widgetType { case .blocks: blocksOptions = widgetsViewModel.getOptions(for: widgetType, as: BlocksWidgetOptions.self) - case .facts: - factsOptions = widgetsViewModel.getOptions(for: widgetType, as: FactsWidgetOptions.self) case .news: newsOptions = widgetsViewModel.getOptions(for: widgetType, as: NewsWidgetOptions.self) case .weather: weatherOptions = widgetsViewModel.getOptions(for: widgetType, as: WeatherWidgetOptions.self) case .price: priceOptions = widgetsViewModel.getOptions(for: widgetType, as: PriceWidgetOptions.self) - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: break } } @@ -196,15 +180,13 @@ class WidgetEditLogic: ObservableObject { switch widgetType { case .blocks: blocksOptions = BlocksWidgetOptions() - case .facts: - factsOptions = FactsWidgetOptions() case .news: newsOptions = NewsWidgetOptions() case .weather: weatherOptions = WeatherWidgetOptions() case .price: priceOptions = PriceWidgetOptions() - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: break } onStateChange?() @@ -214,15 +196,13 @@ class WidgetEditLogic: ObservableObject { switch widgetType { case .blocks: widgetsViewModel.saveOptions(blocksOptions, for: widgetType) - case .facts: - widgetsViewModel.saveOptions(factsOptions, for: widgetType) case .news: widgetsViewModel.saveOptions(newsOptions, for: widgetType) case .weather: widgetsViewModel.saveOptions(weatherOptions, for: widgetType) case .price: widgetsViewModel.saveOptions(priceOptions, for: widgetType) - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: break } } diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 5a347f9c1..cffbb22cd 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -110,32 +110,6 @@ enum WidgetEditItemFactory { return items } - @MainActor - static func getFactsItems(factsViewModel: FactsViewModel, factsOptions: FactsWidgetOptions) -> [WidgetEditItem] { - var items: [WidgetEditItem] = [] - - items.append( - WidgetEditItem( - key: "showTitle", - type: .staticItem, - titleView: AnyView(TitleText(factsViewModel.fact)), - isChecked: true - ) - ) - - items.append( - WidgetEditItem( - key: "showSource", - type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("mempool.space", textColor: .textSecondary)), - isChecked: factsOptions.showSource - ) - ) - - return items - } - @MainActor static func getNewsItems( newsViewModel: NewsViewModel, @@ -365,12 +339,10 @@ enum WidgetEditItemFactory { static func getItems( for widgetType: WidgetType, blocksViewModel: BlocksViewModel, - factsViewModel: FactsViewModel, newsViewModel: NewsViewModel, priceDataByPeriod: [GraphPeriod: [PriceData]] = [:], weatherViewModel: WeatherViewModel, blocksOptions: BlocksWidgetOptions, - factsOptions: FactsWidgetOptions, newsOptions: NewsWidgetOptions, priceOptions: PriceWidgetOptions, weatherOptions: WeatherWidgetOptions @@ -378,15 +350,13 @@ enum WidgetEditItemFactory { switch widgetType { case .blocks: return getBlocksItems(blocksViewModel: blocksViewModel, blocksOptions: blocksOptions) - case .facts: - return getFactsItems(factsViewModel: factsViewModel, factsOptions: factsOptions) case .news: return getNewsItems(newsViewModel: newsViewModel, newsOptions: newsOptions) case .price: return getPriceItems(priceOptions: priceOptions, priceDataByPeriod: priceDataByPeriod) case .weather: return getWeatherItems(weatherViewModel: weatherViewModel, weatherOptions: weatherOptions) - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: return [] } } diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index d7714f5d2..b07374d4a 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -15,7 +15,6 @@ struct WidgetEditView: View { // View models for getting actual content @StateObject private var blocksViewModel = BlocksViewModel.shared - @StateObject private var factsViewModel = FactsViewModel.shared @StateObject private var newsViewModel = NewsViewModel.shared @StateObject private var priceViewModel = PriceViewModel.shared @StateObject private var weatherViewModel = WeatherViewModel.shared @@ -34,12 +33,10 @@ struct WidgetEditView: View { return WidgetEditItemFactory.getItems( for: id, blocksViewModel: blocksViewModel, - factsViewModel: factsViewModel, newsViewModel: newsViewModel, priceDataByPeriod: priceViewModel.dataByPeriod, weatherViewModel: weatherViewModel, blocksOptions: editLogic.blocksOptions, - factsOptions: editLogic.factsOptions, newsOptions: editLogic.newsOptions, priceOptions: editLogic.priceOptions, weatherOptions: editLogic.weatherOptions diff --git a/BitkitWidget/Assets.xcassets/bitcoin.imageset/Contents.json b/BitkitWidget/Assets.xcassets/bitcoin.imageset/Contents.json new file mode 100644 index 000000000..a64f61927 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/bitcoin.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "bitcoin.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/bitcoin.imageset/bitcoin.pdf b/BitkitWidget/Assets.xcassets/bitcoin.imageset/bitcoin.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8085e5f487b0c98fefe28b59f67473616cc37838 GIT binary patch literal 6031 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 E2fv|X=>Px# literal 0 HcmV?d00001 diff --git a/BitkitWidget/Assets.xcassets/btc.imageset/btc.pdf b/BitkitWidget/Assets.xcassets/btc.imageset/btc.pdf deleted file mode 100644 index c40a91328362b5fb2c56574a782e4ff114855f4c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11533 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 TL7*)<6bgYNfP#XmdTRdxv>wJd diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift index 48d2778c4..dfd5eb8f9 100644 --- a/BitkitWidget/BitkitWidget.swift +++ b/BitkitWidget/BitkitWidget.swift @@ -7,5 +7,6 @@ struct BitkitWidgetBundle: WidgetBundle { BitkitPriceWidget() BitkitNewsWidget() BitkitBlocksWidget() + BitkitFactsWidget() } } diff --git a/BitkitWidget/FactsHomeScreenWidget.swift b/BitkitWidget/FactsHomeScreenWidget.swift new file mode 100644 index 000000000..c51b1381d --- /dev/null +++ b/BitkitWidget/FactsHomeScreenWidget.swift @@ -0,0 +1,141 @@ +import SwiftUI +import WidgetKit + +// MARK: - Entry + +struct FactsWidgetEntry: TimelineEntry { + let date: Date + let fact: String +} + +// MARK: - Timeline Provider + +struct FactsWidgetProvider: TimelineProvider { + private static let refreshInterval: TimeInterval = 2 * 60 + + func placeholder(in _: Context) -> FactsWidgetEntry { + FactsWidgetEntry(date: Date(), fact: BitcoinFacts.all[0]) + } + + func getSnapshot(in _: Context, completion: @escaping (FactsWidgetEntry) -> Void) { + completion(entry(at: Date())) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + let nextRefresh = now.addingTimeInterval(Self.refreshInterval) + completion(Timeline(entries: [entry(at: now)], policy: .after(nextRefresh))) + } + + private func entry(at date: Date) -> FactsWidgetEntry { + let bucket = Int(date.timeIntervalSince1970 / Self.refreshInterval) + let index = abs(bucket) % BitcoinFacts.all.count + return FactsWidgetEntry(date: date, fact: BitcoinFacts.all[index]) + } +} + +// MARK: - View + +struct FactsHomeScreenWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode + + var entry: FactsWidgetProvider.Entry + + var body: some View { + content + .containerBackground(for: .widget) { backgroundView } + } + + @ViewBuilder + private var content: some View { + switch widgetFamily { + case .systemSmall: + compactLayout + default: + wideLayout + } + } + + private var compactLayout: some View { + Text(entry.fact) + .font(Fonts.semiBold(size: 17)) + .foregroundColor(textColor) + .lineLimit(4) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .overlay(alignment: .bottomTrailing) { + bitcoinLogo + } + .widgetAccentable() + } + + private var wideLayout: some View { + HStack(alignment: .top, spacing: 32) { + Text(entry.fact) + .font(Fonts.bold(size: 22)) + .foregroundColor(textColor) + .lineLimit(4) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity, alignment: .topLeading) + .widgetAccentable() + + bitcoinLogo + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var bitcoinLogo: some View { + Group { + if widgetRenderingMode == .fullColor { + ZStack { + Circle() + .fill(Color.bitcoin) + + bitcoinGlyph + .foregroundColor(.white) + } + } else { + ZStack { + Circle() + .fill(Color.white) + + bitcoinGlyph + .blendMode(.destinationOut) + } + .compositingGroup() + } + } + .frame(width: 32, height: 32) + } + + private var bitcoinGlyph: some View { + Image("bitcoin") + .resizable() + .renderingMode(.template) + } + + private var backgroundView: some View { + widgetRenderingMode == .fullColor ? Color.gray6 : Color.clear + } + + private var textColor: Color { + widgetRenderingMode == .fullColor ? .white : .primary + } +} + +// MARK: - Widget Configuration + +struct BitkitFactsWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration( + kind: "BitkitFactsWidget", + provider: FactsWidgetProvider() + ) { entry in + FactsHomeScreenWidgetEntryView(entry: entry) + } + .configurationDisplayName("widgets__facts__name") + .description("widgets__facts__description") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} From 2273408fcd7038ffb909824ab1a3861d65107998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Sena?= Date: Mon, 18 May 2026 05:59:29 -0400 Subject: [PATCH 59/60] feat: blocks widget v61 + OS widget (#547) * feat: port price widgets related screens to figma V61 * fix: spacing and alignment * feat: hide menu button from nabigation bar * fix: padding * fix: remove systemLarge widget option * fix: collect results in input order instead of completion order * fix: pr comments * fix: pr comments * refactor: simplify doc * refactor: replace onApper with task * refactor: replace onChange with task id * refactor: simplify comments * refactor: simplyfy comments * refactor: simplify comments * refactor: simplify comments * refactor: simplify comments * refactor: remove multi-pair legacy code * fix: fallback to os widget options after remove in-app * fix: make chart height adaptable * feat: set backgroud color Gray7 * feat: migrate news widget to design v61 and port OS widget * fix: push source text to bottom * refactor: extract articles url to a shared files * feat: open browser on widget click * doc: changelog entry * fix: small and medium sizes displaying different random url * chore: remove schedule file * fix: replace onAppear with task * fix: use stable dafe format identifier * feat: migrate blocks to v61 and implement OS widget * fix: use arrow-up-down for transfer icons * fix: drop large OS widget support * fix: reuse existing text component and remove scale factor * fix: display white32 checkmark for unselected item * fix: vertical padding anchored to checkbox image * fix: remove the gray bg and custom bg from Navigation bar * fix: try to fetch real data for preview * refactor: make string keys generic to be reused in the furue implementtions * fix: make prevew frame height adaptable * fix: display checkmark for title * fix: remove app group fallback * WIP * fixes * fixes * fixes --------- Co-authored-by: Philipp Walter --- Bitkit.xcodeproj/project.pbxproj | 5 + Bitkit/Components/Widgets/BlocksWidget.swift | 174 ++++++------- Bitkit/MainNavView.swift | 2 + Bitkit/Models/BlocksWidgetData.swift | 43 ++++ Bitkit/Models/BlocksWidgetFields.swift | 67 +++++ Bitkit/Models/BlocksWidgetOptions.swift | 68 +++++ .../Localization/en.lproj/Localizable.strings | 3 +- Bitkit/Services/MigrationsService.swift | 24 +- .../BlocksHomeScreenWidgetOptionsStore.swift | 36 +++ Bitkit/Services/Widgets/BlocksService.swift | 147 ++++------- Bitkit/Utilities/WidgetsBackupConverter.swift | 12 +- .../ViewModels/Widgets/BlocksViewModel.swift | 2 +- Bitkit/ViewModels/WidgetsViewModel.swift | 12 + .../Widgets/BlocksWidgetPreviewView.swift | 243 ++++++++++++++++++ Bitkit/Views/Widgets/WidgetEditLogic.swift | 48 ++-- Bitkit/Views/Widgets/WidgetEditModels.swift | 223 +++------------- Bitkit/Views/Widgets/WidgetEditView.swift | 18 +- .../arrow-up-down.imageset/Contents.json | 15 ++ .../arrow-up-down.imageset/arrow-up-down.pdf | Bin 0 -> 4034 bytes .../calendar.imageset/Contents.json | 15 ++ .../calendar.imageset/calendar.pdf | Bin 0 -> 4026 bytes .../clock.imageset/Contents.json | 15 ++ .../Assets.xcassets/clock.imageset/clock.pdf | Bin 0 -> 3922 bytes .../coins.imageset/Contents.json | 15 ++ .../Assets.xcassets/coins.imageset/coins.pdf | Bin 0 -> 11668 bytes .../cube.imageset/Contents.json | 15 ++ .../Assets.xcassets/cube.imageset/cube.pdf | Bin 0 -> 6277 bytes .../file-text.imageset/Contents.json | 15 ++ .../file-text.imageset/file-text.pdf | Bin 0 -> 5903 bytes .../globe.imageset/Contents.json | 15 ++ .../Assets.xcassets/globe.imageset/globe.pdf | Bin 0 -> 4496 bytes BitkitWidget/BitkitWidget.swift | 1 + BitkitWidget/BlocksHomeScreenWidget.swift | 210 +++++++++++++++ BitkitWidget/BlocksWidgetService.swift | 106 ++++++++ changelog.d/next/blocks-widget-v61.added.md | 1 + 35 files changed, 1115 insertions(+), 435 deletions(-) create mode 100644 Bitkit/Models/BlocksWidgetData.swift create mode 100644 Bitkit/Models/BlocksWidgetFields.swift create mode 100644 Bitkit/Models/BlocksWidgetOptions.swift create mode 100644 Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift create mode 100644 Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift create mode 100644 BitkitWidget/Assets.xcassets/arrow-up-down.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/arrow-up-down.imageset/arrow-up-down.pdf create mode 100644 BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/calendar.imageset/calendar.pdf create mode 100644 BitkitWidget/Assets.xcassets/clock.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/clock.imageset/clock.pdf create mode 100644 BitkitWidget/Assets.xcassets/coins.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/coins.imageset/coins.pdf create mode 100644 BitkitWidget/Assets.xcassets/cube.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/cube.imageset/cube.pdf create mode 100644 BitkitWidget/Assets.xcassets/file-text.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/file-text.imageset/file-text.pdf create mode 100644 BitkitWidget/Assets.xcassets/globe.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/globe.imageset/globe.pdf create mode 100644 BitkitWidget/BlocksHomeScreenWidget.swift create mode 100644 BitkitWidget/BlocksWidgetService.swift create mode 100644 changelog.d/next/blocks-widget-v61.added.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 15e22deb6..703b0c05a 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -108,6 +108,7 @@ "Extensions/LDKNode+AddressType.swift", Extensions/PaymentDetails.swift, Models/BlocktankNotificationType.swift, + Models/BlocksWidgetOptions.swift, Models/LnPeer.swift, Models/PubkyPublicKeyFormat.swift, Models/Toast.swift, @@ -173,10 +174,14 @@ Fonts/InterTight-Regular.ttf, Constants/WidgetEnv.swift, Fonts/InterTight-SemiBold.ttf, + Models/BlocksWidgetData.swift, + Models/BlocksWidgetFields.swift, + Models/BlocksWidgetOptions.swift, Models/NewsWidgetData.swift, Models/NewsWidgetOptions.swift, Models/PriceWidgetData.swift, Models/PriceWidgetOptions.swift, + Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift, Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift, Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift, Styles/Colors.swift, diff --git a/Bitkit/Components/Widgets/BlocksWidget.swift b/Bitkit/Components/Widgets/BlocksWidget.swift index 5b6cda4bf..3344d0ae5 100644 --- a/Bitkit/Components/Widgets/BlocksWidget.swift +++ b/Bitkit/Components/Widgets/BlocksWidget.swift @@ -1,34 +1,16 @@ 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 -} +// MARK: - Widget -/// A widget that displays Bitcoin block information +/// In-app Bitcoin Blocks widget (v61). Renders the wide layout — used inside the home feed +/// and the wide carousel page on the preview screen. struct BlocksWidget: View { - /// Configuration options for the widget var options: BlocksWidgetOptions = .init() - - /// Flag indicating if the widget is in editing mode var isEditing: Bool = false - - /// Callback to signal when editing should end var onEditingEnd: (() -> Void)? - /// View model for handling block data @StateObject private var viewModel = BlocksViewModel.shared - /// Initialize the widget init( options: BlocksWidgetOptions = BlocksWidgetOptions(), isEditing: Bool = false, @@ -39,96 +21,96 @@ 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, isEditing: isEditing, onEditingEnd: onEditingEnd ) { - VStack(spacing: 0) { - if viewModel.isLoading { - WidgetContentBuilder.loadingView() - } else if viewModel.error != nil { - WidgetContentBuilder.errorView(t("widgets__blocks__error")) - } else if let data = viewModel.blockData { - VStack(spacing: 0) { - // Display block data rows based on options - ForEach(getDisplayableData(data), id: \.key) { item in - HStack(spacing: 0) { - HStack { - BodySSBText(item.label, textColor: .textSecondary) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) - - HStack { - BodyMSBText(item.value) - .lineLimit(1) - .truncationMode(.middle) - } - .frame(maxWidth: .infinity, alignment: .trailing) - } - .frame(minHeight: 28) - } - - if options.showSource { - WidgetContentBuilder.sourceRow(source: "mempool.space") - } - } - } - } + content } - .onAppear { + .task { 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)) + @ViewBuilder + private var content: some View { + if viewModel.isLoading && viewModel.blockData == nil { + WidgetContentBuilder.loadingView() + } else if viewModel.error != nil && viewModel.blockData == nil { + WidgetContentBuilder.errorView(t("widgets__blocks__error")) + } else if let data = viewModel.blockData { + BlocksWidgetWideContent(data: data, options: options) } - 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)) + } +} + +// MARK: - Wide layout (in-app + 343-wide carousel page + .systemMedium / .systemLarge OS widget) + +struct BlocksWidgetWideContent: View { + let data: CachedBlock + let options: BlocksWidgetOptions + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(options.enabledFields, id: \.self) { field in + BlocksWidgetWideRow(field: field, value: field.value(from: data)) + } } - if options.merkleRoot { - items.append((key: "merkleRoot", label: blocksMapping["merkleRoot"]!, value: data.merkleRoot)) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct BlocksWidgetWideRow: View { + let field: BlocksWidgetField + let value: String + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image(field.iconName) + .resizable() + .renderingMode(.template) + .foregroundColor(.brandAccent) + .frame(width: 20, height: 20) + + BodyMText(field.label, textColor: .white80) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + BodyMSBText(value) + .lineLimit(1) + .truncationMode(.middle) } + } +} + +// MARK: - Compact layout (small carousel preview + 163×192 OS small widget) - return items +struct BlocksWidgetCompactContent: View { + let data: CachedBlock + let options: BlocksWidgetOptions + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(options.enabledFields, id: \.self) { field in + HStack(alignment: .center, spacing: 8) { + Image(field.iconName) + .resizable() + .renderingMode(.template) + .foregroundColor(.brandAccent) + .frame(width: 20, height: 20) + + BodySSBText(field.value(from: data)) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.gray6) + .cornerRadius(16) } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index ba6c2e3f4..136ee5ff1 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -443,6 +443,8 @@ struct MainNavView: View { PriceWidgetPreviewView() case .news: NewsWidgetPreviewView() + case .blocks: + BlocksWidgetPreviewView() default: WidgetDetailView(id: widgetType) } diff --git a/Bitkit/Models/BlocksWidgetData.swift b/Bitkit/Models/BlocksWidgetData.swift new file mode 100644 index 000000000..f3f9c0d08 --- /dev/null +++ b/Bitkit/Models/BlocksWidgetData.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Persistable representation of the latest mined block, shared between the main app and the +/// widget extension via the App Group. Strings are pre-formatted by the main-app `BlocksService` +/// so the widget extension can render without re-running locale-sensitive formatting. +struct CachedBlock: Codable, Equatable { + let height: String + let time: String + let date: String + let transactionCount: String + let size: String + let fees: String +} + +/// Cache reader/writer used by both the main app and the widget extension. +enum BlocksWidgetCache { + static let appGroupSuiteName = "group.bitkit" + private static let latestKey = "blocks_widget_latest_v1" + private static let legacyStandardKey = "blocks_widget_cache" + + private static func defaults() -> UserDefaults { + UserDefaults(suiteName: appGroupSuiteName) ?? .standard + } + + static func saveLatest(_ block: CachedBlock) { + guard let encoded = try? JSONEncoder().encode(block) else { return } + defaults().set(encoded, forKey: latestKey) + } + + static func loadLatest() -> CachedBlock? { + guard let data = defaults().data(forKey: latestKey), + let decoded = try? JSONDecoder().decode(CachedBlock.self, from: data) + else { + return nil + } + return decoded + } + + /// One-time cleanup of the pre-App-Group cache that lived in `UserDefaults.standard`. + static func legacyDropStandardSuiteCache() { + UserDefaults.standard.removeObject(forKey: legacyStandardKey) + } +} diff --git a/Bitkit/Models/BlocksWidgetFields.swift b/Bitkit/Models/BlocksWidgetFields.swift new file mode 100644 index 000000000..c8dfa75b8 --- /dev/null +++ b/Bitkit/Models/BlocksWidgetFields.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Ordered field set used by the v61 Blocks widget. +/// +/// Shared between the main app and the WidgetKit extension via the App Group target membership. +/// Labels are intentionally hardcoded English to avoid reaching into the main app's +/// `LocalizeHelpers` from the widget extension. +enum BlocksWidgetField: String, CaseIterable { + case height + case time + case date + case transactionCount + case size + case fees + + var label: String { + switch self { + case .height: return "Block" + case .time: return "Time" + case .date: return "Date" + case .transactionCount: return "Transactions" + case .size: return "Size" + case .fees: return "Fees" + } + } + + /// Asset name for the brand-orange icon used in both the wide and compact layouts. + var iconName: String { + switch self { + case .height: return "cube" + case .time: return "clock" + case .date: return "calendar" + case .transactionCount: return "arrow-up-down" + case .size: return "file-text" + case .fees: return "coins" + } + } + + func isEnabled(in options: BlocksWidgetOptions) -> Bool { + switch self { + case .height: return options.height + case .time: return options.time + case .date: return options.date + case .transactionCount: return options.transactionCount + case .size: return options.size + case .fees: return options.fees + } + } + + func value(from data: CachedBlock) -> String { + switch self { + case .height: return data.height + case .time: return data.time + case .date: return data.date + case .transactionCount: return data.transactionCount + case .size: return data.size + case .fees: return data.fees + } + } +} + +extension BlocksWidgetOptions { + /// All enabled fields in declared order. + var enabledFields: [BlocksWidgetField] { + BlocksWidgetField.allCases.filter { $0.isEnabled(in: self) } + } +} diff --git a/Bitkit/Models/BlocksWidgetOptions.swift b/Bitkit/Models/BlocksWidgetOptions.swift new file mode 100644 index 000000000..0d6838d6d --- /dev/null +++ b/Bitkit/Models/BlocksWidgetOptions.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Options for configuring the in-app and home-screen Bitcoin Blocks widgets (shared via App Group). +struct BlocksWidgetOptions: Codable, Equatable { + var height: Bool = true + var time: Bool = true + var date: Bool = true + var transactionCount: Bool = true + var size: Bool = false + var fees: Bool = false + + init( + height: Bool = true, + time: Bool = true, + date: Bool = true, + transactionCount: Bool = true, + size: Bool = false, + fees: Bool = false + ) { + self.height = height + self.time = time + self.date = date + self.transactionCount = transactionCount + self.size = size + self.fees = fees + limitEnabledFields() + } + + private enum CodingKeys: String, CodingKey { + case height + case time + case date + case transactionCount + case size + case fees + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + height = try container.decodeIfPresent(Bool.self, forKey: .height) ?? true + time = try container.decodeIfPresent(Bool.self, forKey: .time) ?? true + date = try container.decodeIfPresent(Bool.self, forKey: .date) ?? true + transactionCount = try container.decodeIfPresent(Bool.self, forKey: .transactionCount) ?? true + size = try container.decodeIfPresent(Bool.self, forKey: .size) ?? false + fees = try container.decodeIfPresent(Bool.self, forKey: .fees) ?? false + limitEnabledFields() + } + + private mutating func limitEnabledFields() { + let fields: [WritableKeyPath] = [ + \.height, + \.time, + \.date, + \.transactionCount, + \.size, + \.fees, + ] + + var enabledCount = 0 + for field in fields where self[keyPath: field] { + if enabledCount < 4 { + enabledCount += 1 + } else { + self[keyPath: field] = false + } + } + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 842a2462b..a74556768 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1405,8 +1405,9 @@ "widgets__news__error" = "Couldn\'t get the latest news"; "widgets__news__content_header" = "Content"; "widgets__blocks__name" = "Bitcoin Blocks"; -"widgets__blocks__description" = "Examine various statistics on newly mined Bitcoin Blocks."; +"widgets__blocks__description" = "Examine various statistics on newly mined Bitcoin Blocks. Powered by mempool.space."; "widgets__blocks__error" = "Couldn\'t get blocks data"; +"widgets__blocks__data_header" = "Data (max 4)"; "widgets__facts__name" = "Bitcoin Facts"; "widgets__facts__description" = "Discover fun facts about Bitcoin, every time you open your wallet."; "widgets__calculator__name" = "Bitcoin Calculator"; diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index b3d3c5f3a..43a0a8370 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -270,19 +270,6 @@ private struct MigrationNewsWidgetOptions: Codable { var showSource: Bool } -private struct MigrationBlocksWidgetOptions: Codable { - var height: Bool - var time: Bool - var date: Bool - var transactionCount: Bool - var size: Bool - var weight: Bool - var difficulty: Bool - var hash: Bool - var merkleRoot: Bool - var showSource: Bool -} - private struct MigrationFactsWidgetOptions: Codable { var showSource: Bool } @@ -1962,17 +1949,12 @@ extension MigrationsService { let blocksPrefs = (widgetsDict["blocksPreferences"] as? [String: Any]) ?? (widgetsDict["blocks"] as? [String: Any]) if let prefs = blocksPrefs { - let options = MigrationBlocksWidgetOptions( + let options = BlocksWidgetOptions( height: getBool(from: prefs, key: "height", fallbackKey: "showBlock", defaultValue: true), time: getBool(from: prefs, key: "time", fallbackKey: "showTime", defaultValue: true), date: getBool(from: prefs, key: "date", fallbackKey: "showDate", defaultValue: true), - transactionCount: getBool(from: prefs, key: "transactionCount", fallbackKey: "showTransactions", defaultValue: false), - size: getBool(from: prefs, key: "size", fallbackKey: "showSize", defaultValue: false), - weight: getBool(from: prefs, key: "weight", defaultValue: false), - difficulty: getBool(from: prefs, key: "difficulty", defaultValue: false), - hash: getBool(from: prefs, key: "hash", defaultValue: false), - merkleRoot: getBool(from: prefs, key: "merkleRoot", defaultValue: false), - showSource: getBool(from: prefs, key: "showSource", defaultValue: false) + transactionCount: getBool(from: prefs, key: "transactionCount", fallbackKey: "showTransactions", defaultValue: true), + size: getBool(from: prefs, key: "size", fallbackKey: "showSize", defaultValue: false) ) if let data = try? JSONEncoder().encode(options) { result["blocks"] = data diff --git a/Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift new file mode 100644 index 000000000..7b23a1f23 --- /dev/null +++ b/Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift @@ -0,0 +1,36 @@ +import Foundation +import WidgetKit + +/// Mirrors in-app Blocks widget options into the App Group so the WidgetKit extension can read them, +/// and centralizes the WidgetKit reload trigger for the Blocks home-screen widget. +enum BlocksHomeScreenWidgetOptionsStore { + /// WidgetKit `kind` for the home-screen Blocks widget (must match `BitkitBlocksWidget`). + static let blocksHomeScreenWidgetKind = "BitkitBlocksWidget" + + 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 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: blocksHomeScreenWidgetKind) + } +} diff --git a/Bitkit/Services/Widgets/BlocksService.swift b/Bitkit/Services/Widgets/BlocksService.swift index 4f00895c3..6e5df6d80 100644 --- a/Bitkit/Services/Widgets/BlocksService.swift +++ b/Bitkit/Services/Widgets/BlocksService.swift @@ -1,129 +1,94 @@ import Foundation -/// Service for fetching and caching Bitcoin block data +/// Service for fetching and caching the latest mined Bitcoin block. +/// +/// Writes the result to the App Group cache (`BlocksWidgetCache`) so the WidgetKit extension +/// can surface the same data, and triggers a timeline reload on the home-screen widget after +/// a successful fresh fetch. class BlocksService { static let shared = BlocksService() - 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 - private init() {} + private init() { + BlocksWidgetCache.legacyDropStandardSuiteCache() + } - /// Fetches the latest block data using stale-while-revalidate strategy - /// - Parameter returnCachedImmediately: If true, returns cached data immediately if available - /// - Returns: Block data - /// - Throws: URLError or decoding error + /// Fetches the latest block data using stale-while-revalidate strategy. + /// - Parameter returnCachedImmediately: If true, returns cached data immediately if available. @discardableResult - func fetchBlockData(returnCachedImmediately: Bool = true) async throws -> BlockData { - // If we want cached data and it exists, return it immediately + func fetchBlockData(returnCachedImmediately: Bool = true) async throws -> CachedBlock { if returnCachedImmediately, let cachedData = getCachedData() { - // Start fresh fetch in background to update cache (don't await) + // Background refresh; cache is updated automatically inside fetchFreshData. Task { do { try await fetchFreshData() - // Cache will be updated automatically in fetchFreshData } catch { - // Silent failure for background updates print("Background blocks data update failed: \(error)") } } return cachedData } - // No cache available or cache not requested - fetch fresh data return try await fetchFreshData() } - /// Fetches fresh data from API (always hits the network) + /// Fetches fresh data from the mempool API. @discardableResult - private func fetchFreshData() async throws -> BlockData { - // First get the tip hash + private func fetchFreshData() async throws -> CachedBlock { guard let tipUrl = URL(string: "\(baseUrl)/blocks/tip/hash") else { throw URLError(.badURL) } let (hashData, hashResponse) = try await URLSession.shared.data(from: tipUrl) - // Validate HTTP response - guard let httpResponse = hashResponse as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - guard httpResponse.statusCode == 200 else { + guard let httpResponse = hashResponse as? HTTPURLResponse, + httpResponse.statusCode == 200 + else { throw URLError(.badServerResponse) } let hash = String(data: hashData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - // Now get the block info - guard let blockUrl = URL(string: "\(baseUrl)/block/\(hash)") else { + // The v1 endpoint returns the same fields as the legacy one plus an `extras` block with `totalFees`. + guard let blockUrl = URL(string: "\(baseUrl)/v1/block/\(hash)") else { throw URLError(.badURL) } let (blockData, blockResponse) = try await URLSession.shared.data(from: blockUrl) - // Validate HTTP response - guard let httpBlockResponse = blockResponse as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - guard httpBlockResponse.statusCode == 200 else { + guard let httpBlockResponse = blockResponse as? HTTPURLResponse, + httpBlockResponse.statusCode == 200 + else { throw URLError(.badServerResponse) } - do { - let decoder = JSONDecoder() - let blockInfo = try decoder.decode(BlockInfo.self, from: blockData) - let formattedData = formatBlockInfo(blockInfo) + let blockInfo = try JSONDecoder().decode(BlockInfo.self, from: blockData) + let formattedData = formatBlockInfo(blockInfo) - // Cache the data - cacheData(formattedData) + cacheData(formattedData) + BlocksHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() - return formattedData - } catch { - throw error - } + return formattedData } - /// 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) - cache.set(encoded, forKey: cacheKey) - } catch { - // Handle silently - } + /// Caches block data to the App Group so the WidgetKit extension can read it. + func cacheData(_ data: CachedBlock) { + BlocksWidgetCache.saveLatest(data) } - /// Retrieves cached block data - /// - Returns: Block data if available - func getCachedData() -> BlockData? { - guard let data = cache.data(forKey: cacheKey) else { - return nil - } - - do { - let decoder = JSONDecoder() - return try decoder.decode(BlockData.self, from: data) - } catch { - return nil - } + /// Retrieves cached block data from the App Group. + func getCachedData() -> CachedBlock? { + BlocksWidgetCache.loadLatest() } - /// Formats raw block info into display-friendly format - /// - Parameter blockInfo: Raw block info from API - /// - Returns: Formatted block data - private func formatBlockInfo(_ blockInfo: BlockInfo) -> BlockData { + /// Formats raw block info into display-friendly format. + private func formatBlockInfo(_ blockInfo: BlockInfo) -> CachedBlock { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.locale = Locale.current - let difficulty = (blockInfo.difficulty / 1_000_000_000_000).formatted(.number.precision(.fractionLength(2))) - let size = Double(blockInfo.size) / 1024 - let weight = Double(blockInfo.weight) / 1024 / 1024 + let sizeKb = Double(blockInfo.size) / 1024 let timeFormatter = DateFormatter() timeFormatter.dateStyle = .none @@ -138,25 +103,24 @@ class BlocksService { let dateString = dateFormatter.string(from: date) let formattedHeight = formatter.string(from: NSNumber(value: blockInfo.height)) ?? "\(blockInfo.height)" - let formattedSize = "\(formatter.string(from: NSNumber(value: Int(size))) ?? "\(Int(size))") KB" + let formattedSize = "\(formatter.string(from: NSNumber(value: Int(sizeKb))) ?? "\(Int(sizeKb))") KB" let formattedTransactions = formatter.string(from: NSNumber(value: blockInfo.txCount)) ?? "\(blockInfo.txCount)" - let formattedWeight = "\(formatter.string(from: NSNumber(value: weight)) ?? "\(weight)") MWU" - return BlockData( - hash: blockInfo.id, - difficulty: difficulty, - size: formattedSize, - weight: formattedWeight, + let totalFeesSats = blockInfo.extras?.totalFees ?? 0 + let formattedFees = formatter.string(from: NSNumber(value: totalFeesSats)) ?? "\(totalFeesSats)" + + return CachedBlock( height: formattedHeight, time: time, date: dateString, transactionCount: formattedTransactions, - merkleRoot: blockInfo.merkleRoot + size: formattedSize, + fees: formattedFees ) } } -/// Raw block info model from mempool.space API +/// Raw block info model from mempool.space API (`/api/v1/block/:hash`). struct BlockInfo: Codable { let id: String let height: Int @@ -164,8 +128,11 @@ struct BlockInfo: Codable { let txCount: Int let size: Int let weight: Int - let difficulty: Double - let merkleRoot: String + let extras: Extras? + + struct Extras: Codable { + let totalFees: Int? + } enum CodingKeys: String, CodingKey { case id @@ -174,20 +141,6 @@ struct BlockInfo: Codable { case txCount = "tx_count" case size case weight - case difficulty - case merkleRoot = "merkle_root" + case extras } } - -/// Formatted block data for display -struct BlockData: Codable { - let hash: String - let difficulty: String - let size: String - let weight: String - let height: String - let time: String - let date: String - let transactionCount: String - let merkleRoot: String -} diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 6d0e392c8..7783d31f8 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -32,7 +32,7 @@ enum WidgetsBackupConverter { "showDate": options.date, "showTransactions": options.transactionCount, "showSize": options.size, - "showSource": options.showSource, + "showFees": options.fees, ] } case .news: @@ -125,13 +125,9 @@ enum WidgetsBackupConverter { height: prefs["showBlock"] as? Bool ?? true, time: prefs["showTime"] as? Bool ?? true, date: prefs["showDate"] as? Bool ?? true, - transactionCount: prefs["showTransactions"] as? Bool ?? false, + transactionCount: prefs["showTransactions"] as? Bool ?? true, size: prefs["showSize"] as? Bool ?? false, - weight: false, - difficulty: false, - hash: false, - merkleRoot: false, - showSource: prefs["showSource"] as? Bool ?? false + fees: prefs["showFees"] as? Bool ?? false ) optionsData = try? JSONEncoder().encode(iosOptions) } @@ -201,7 +197,7 @@ enum WidgetsBackupConverter { "showDate": defaults.date, "showTransactions": defaults.transactionCount, "showSize": defaults.size, - "showSource": defaults.showSource, + "showFees": defaults.fees, ] } diff --git a/Bitkit/ViewModels/Widgets/BlocksViewModel.swift b/Bitkit/ViewModels/Widgets/BlocksViewModel.swift index 2bbf1b0ad..be29b597b 100644 --- a/Bitkit/ViewModels/Widgets/BlocksViewModel.swift +++ b/Bitkit/ViewModels/Widgets/BlocksViewModel.swift @@ -6,7 +6,7 @@ import SwiftUI class BlocksViewModel: ObservableObject { static let shared = BlocksViewModel() - @Published var blockData: BlockData? + @Published var blockData: CachedBlock? @Published var isLoading = false @Published var error: Error? diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 785346ae2..73020328f 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -263,6 +263,10 @@ class WidgetsViewModel: ObservableObject { if type == .news, let newsOptions = options as? NewsWidgetOptions { syncNewsOptionsToHomeScreenWidget(newsOptions) } + + if type == .blocks, let blocksOptions = options as? BlocksWidgetOptions { + syncBlocksOptionsToHomeScreenWidget(blocksOptions) + } } catch { print("Failed to save widget options: \(error)") } @@ -336,4 +340,12 @@ class WidgetsViewModel: ObservableObject { NewsHomeScreenWidgetOptionsStore.save(options) NewsHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } + + /// Mirrors in-app blocks widget options to the App Group so the home-screen WidgetKit widget can read them. + /// Only invoked when the user explicitly changes blocks widget options — adding, deleting, or resetting + /// in-app widgets must not affect the independent OS home-screen widget. + private func syncBlocksOptionsToHomeScreenWidget(_ options: BlocksWidgetOptions) { + BlocksHomeScreenWidgetOptionsStore.save(options) + BlocksHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() + } } diff --git a/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift b/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift new file mode 100644 index 000000000..55796e081 --- /dev/null +++ b/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift @@ -0,0 +1,243 @@ +import SwiftUI + +/// Preview screen for the Bitcoin Blocks widget. +struct BlocksWidgetPreviewView: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + + @StateObject private var viewModel = BlocksViewModel.shared + + @State private var carouselPage: Int = 0 + @State private var showDeleteAlert = false + + private let widgetType: WidgetType = .blocks + + private var widgetName: String { + t("widgets__blocks__name") + } + + private var widgetDescription: String { + t("widgets__blocks__description") + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(widgetType) + } + + private var hasCustomOptions: Bool { + widgets.hasCustomOptions(for: widgetType) + } + + private var currentOptions: BlocksWidgetOptions { + widgets.getOptions(for: widgetType, as: BlocksWidgetOptions.self) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + NavigationBar(title: widgetName, showMenuButton: false) + + VStack(alignment: .leading, spacing: 0) { + BodyMText(widgetDescription, textColor: .textSecondary) + .padding(.bottom, 16) + + Divider().background(Color.white.opacity(0.1)) + + widgetSettingsRow + + Divider().background(Color.white.opacity(0.1)) + } + + VStack(spacing: 16) { + carousel + + sizeLabel + + pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + buttonsRow + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .task { + viewModel.startUpdates() + } + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteAlert, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } + Button(t("common__delete_yes"), role: .destructive) { onDelete() } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": widgetName])) + } + ) + } + + // MARK: - Widget Settings cell + + private var widgetSettingsRow: some View { + Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { + HStack(alignment: .center, spacing: 0) { + BodyMText(t("widgets__widget__settings"), textColor: .textPrimary) + + Spacer() + + BodyMText( + hasCustomOptions + ? t("widgets__widget__edit_custom") + : t("widgets__widget__edit_default"), + textColor: .textSecondary + ) + + Image("chevron") + .resizable() + .foregroundColor(.textSecondary) + .frame(width: 24, height: 24) + .padding(.leading, 5) + } + .frame(maxWidth: .infinity, minHeight: 51) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("WidgetEdit") + } + + // MARK: - Carousel + + private var carousel: some View { + TabView(selection: $carouselPage) { + compactPage.tag(0) + widePage.tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(maxHeight: .infinity) + } + + private var compactPage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = viewModel.blockData { + BlocksWidgetCompactContent(data: data, options: currentOptions) + } else { + placeholderCompact + } + } + .frame(width: 163, height: 192) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private var widePage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = viewModel.blockData { + BlocksWidgetWideContent(data: data, options: currentOptions) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + placeholderWide + } + } + .frame(maxWidth: .infinity) + Spacer(minLength: 0) + } + } + + private var placeholderCompact: some View { + Color.gray6 + .cornerRadius(16) + .overlay(ProgressView()) + } + + private var placeholderWide: some View { + Color.gray6 + .cornerRadius(16) + .frame(height: 180) + .overlay(ProgressView()) + } + + // MARK: - Size label & page indicator + + private var sizeLabel: some View { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__widget__size_small") + : t("widgets__widget__size_wide"), + textColor: .textSecondary + ) + .textCase(.uppercase) + Spacer() + } + } + + private var pageIndicator: some View { + HStack(spacing: 8) { + Spacer() + ForEach(0 ..< 2, id: \.self) { index in + Circle() + .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) + .frame(width: 8, height: 8) + } + Spacer() + } + } + + // MARK: - Buttons + + private var buttonsRow: some View { + HStack(spacing: 16) { + if isWidgetSaved { + CustomButton( + title: t("common__delete"), + variant: .secondary, + size: .large, + shouldExpand: true + ) { + showDeleteAlert = true + } + .accessibilityIdentifier("WidgetDelete") + } + + CustomButton( + title: t("widgets__widget__save_widget"), + variant: .primary, + size: .large, + shouldExpand: true, + action: onSave + ) + .accessibilityIdentifier("WidgetSave") + } + } + + // MARK: - Actions + + private func onSave() { + widgets.saveWidget(widgetType) + navigation.reset() + } + + private func onDelete() { + widgets.deleteWidget(widgetType) + navigation.reset() + } +} + +#Preview { + NavigationStack { + BlocksWidgetPreviewView() + .environmentObject(NavigationViewModel()) + .environmentObject(WidgetsViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index 1b135d91a..b530ff4f0 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -34,11 +34,16 @@ class WidgetEditLogic: ObservableObject { var hasEnabledOption: Bool { switch widgetType { case .blocks: - // Blocks widget has many options, check if any are enabled - return blocksOptions.height || blocksOptions.time || blocksOptions.date || blocksOptions.transactionCount || blocksOptions.size - || blocksOptions.weight || blocksOptions.difficulty || blocksOptions.hash || blocksOptions.merkleRoot || blocksOptions.showSource - case .news, .facts: - // Static items (showTitle) are always enabled, so these widgets always have enabled options + return blocksOptions.height + || blocksOptions.time + || blocksOptions.date + || blocksOptions.transactionCount + || blocksOptions.size + || blocksOptions.fees + case .news: + return newsOptions.showTitle || newsOptions.showSource || newsOptions.showDate + case .facts: + // Facts widget's static title is always shown, so it always has an enabled option return true case .weather: // Weather widget has multiple options, check if any are enabled @@ -83,25 +88,23 @@ class WidgetEditLogic: ObservableObject { case .blocks: switch item.key { case "height": + guard canToggleBlockOption(blocksOptions.height) else { break } blocksOptions.height.toggle() case "time": + guard canToggleBlockOption(blocksOptions.time) else { break } blocksOptions.time.toggle() case "date": + guard canToggleBlockOption(blocksOptions.date) else { break } blocksOptions.date.toggle() case "transactionCount": + guard canToggleBlockOption(blocksOptions.transactionCount) else { break } blocksOptions.transactionCount.toggle() case "size": + guard canToggleBlockOption(blocksOptions.size) else { break } blocksOptions.size.toggle() - case "weight": - blocksOptions.weight.toggle() - case "difficulty": - blocksOptions.difficulty.toggle() - case "hash": - blocksOptions.hash.toggle() - case "merkleRoot": - blocksOptions.merkleRoot.toggle() - case "showSource": - blocksOptions.showSource.toggle() + case "fees": + guard canToggleBlockOption(blocksOptions.fees) else { break } + blocksOptions.fees.toggle() default: break } @@ -157,6 +160,21 @@ class WidgetEditLogic: ObservableObject { onStateChange?() } + private func canToggleBlockOption(_ isCurrentlyEnabled: Bool) -> Bool { + isCurrentlyEnabled || enabledBlockOptionsCount < 4 + } + + private var enabledBlockOptionsCount: Int { + [ + blocksOptions.height, + blocksOptions.time, + blocksOptions.date, + blocksOptions.transactionCount, + blocksOptions.size, + blocksOptions.fees, + ].filter { $0 }.count + } + func loadCurrentOptions() { switch widgetType { case .blocks: diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 74852fd92..5a347f9c1 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -69,205 +69,40 @@ enum WidgetEditItemFactory { ) -> [WidgetEditItem] { var items: [WidgetEditItem] = [] - if let data = blocksViewModel.blockData { - items.append( - WidgetEditItem( - key: "height", - type: .toggleItem, - title: "Block", - value: data.height, - isChecked: blocksOptions.height - ) - ) - - items.append( - WidgetEditItem( - key: "time", - type: .toggleItem, - title: "Time", - value: data.time, - isChecked: blocksOptions.time - ) - ) - - items.append( - WidgetEditItem( - key: "date", - type: .toggleItem, - title: "Date", - value: data.date, - isChecked: blocksOptions.date - ) - ) - - items.append( - WidgetEditItem( - key: "transactionCount", - type: .toggleItem, - title: "Transactions", - value: data.transactionCount, - isChecked: blocksOptions.transactionCount - ) - ) - - items.append( - WidgetEditItem( - key: "size", - type: .toggleItem, - title: "Size", - value: data.size, - isChecked: blocksOptions.size - ) - ) + items.append(sectionHeaderItem(key: "blocks_data_header", title: t("widgets__blocks__data_header"))) - items.append( - WidgetEditItem( - key: "weight", - type: .toggleItem, - title: "Weight", - value: data.weight, - isChecked: blocksOptions.weight - ) - ) + let fallback: [BlocksWidgetField: String] = [ + .height: "870,123", + .time: "2:45:30 PM", + .date: "Dec 15, 2024", + .transactionCount: "3,456", + .size: "1,234 KB", + .fees: "25,059,357", + ] - items.append( - WidgetEditItem( - key: "difficulty", - type: .toggleItem, - title: "Difficulty", - value: data.difficulty, - isChecked: blocksOptions.difficulty - ) - ) + for field in BlocksWidgetField.allCases { + let value: String = { + if let data = blocksViewModel.blockData { return field.value(from: data) } + return fallback[field] ?? "" + }() - items.append( - WidgetEditItem( - key: "hash", - type: .toggleItem, - title: "Hash", - value: data.hash, - isChecked: blocksOptions.hash - ) + let titleView = AnyView( + HStack(spacing: 8) { + Image(field.iconName) + .resizable() + .renderingMode(.template) + .foregroundColor(.brandAccent) + .frame(width: 20, height: 20) + BodySSBText(field.label, textColor: .textSecondary) + } ) - - items.append( - WidgetEditItem( - key: "merkleRoot", - type: .toggleItem, - title: "Merkle Root", - value: data.merkleRoot, - isChecked: blocksOptions.merkleRoot - ) - ) - - items.append( - WidgetEditItem( - key: "showSource", - type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("mempool.space", textColor: .textSecondary)), - isChecked: blocksOptions.showSource - ) - ) - } else { - // Fallback when no data is available items.append( WidgetEditItem( - key: "height", + key: field.rawValue, type: .toggleItem, - title: "Block", - value: "870,123", - isChecked: blocksOptions.height - ) - ) - - items.append( - WidgetEditItem( - key: "time", - type: .toggleItem, - title: "Time", - value: "2:45:30 PM", - isChecked: blocksOptions.time - ) - ) - - items.append( - WidgetEditItem( - key: "date", - type: .toggleItem, - title: "Date", - value: "Dec 15, 2024", - isChecked: blocksOptions.date - ) - ) - - items.append( - WidgetEditItem( - key: "transactionCount", - type: .toggleItem, - title: "Transactions", - value: "3,456", - isChecked: blocksOptions.transactionCount - ) - ) - - items.append( - WidgetEditItem( - key: "size", - type: .toggleItem, - title: "Size", - value: "1,234 KB", - isChecked: blocksOptions.size - ) - ) - - items.append( - WidgetEditItem( - key: "weight", - type: .toggleItem, - title: "Weight", - value: "3.45 MWU", - isChecked: blocksOptions.weight - ) - ) - - items.append( - WidgetEditItem( - key: "difficulty", - type: .toggleItem, - title: "Difficulty", - value: "102.45 T", - isChecked: blocksOptions.difficulty - ) - ) - - items.append( - WidgetEditItem( - key: "hash", - type: .toggleItem, - title: "Hash", - value: "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054", - isChecked: blocksOptions.hash - ) - ) - - items.append( - WidgetEditItem( - key: "merkleRoot", - type: .toggleItem, - title: "Merkle Root", - value: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", - isChecked: blocksOptions.merkleRoot - ) - ) - - items.append( - WidgetEditItem( - key: "showSource", - type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("mempool.space", textColor: .textSecondary)), - isChecked: blocksOptions.showSource + titleView: titleView, + valueView: AnyView(BodySSBText(value, textColor: .textSecondary)), + isChecked: field.isEnabled(in: blocksOptions) ) ) } @@ -314,10 +149,10 @@ enum WidgetEditItemFactory { items.append( WidgetEditItem( key: "showTitle", - type: .staticItem, + type: .toggleItem, titleView: AnyView(TitleText(data.title)), valueView: nil, - isChecked: true // Static items are always shown + isChecked: newsOptions.showTitle ) ) diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index 67a5094c2..d7714f5d2 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -55,14 +55,28 @@ struct WidgetEditView: View { editLogic?.resetOptions() } + /// v61 widget configuration screens (Price, News, Blocks) use the widget name as the title + /// and skip the legacy description block. + private var usesV61Header: Bool { + id == .price || id == .blocks + } + var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar( - title: id == .price ? widget.name : t("widgets__widget__edit"), - showMenuButton: id != .price + title: usesV61Header ? widget.name : t("widgets__widget__edit"), + showMenuButton: !usesV61Header ) .padding(.bottom, 16) + if !usesV61Header { + BodyMText( + t("widgets__widget__edit_description", variables: ["name": widget.name]), + textColor: .textSecondary + ) + .padding(.bottom, 16) + } + ScrollView(showsIndicators: false) { LazyVStack(spacing: 0) { ForEach(getItems(), id: \.key) { item in diff --git a/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/Contents.json b/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/Contents.json new file mode 100644 index 000000000..ae0bb361f --- /dev/null +++ b/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "arrow-up-down.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/arrow-up-down.pdf b/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/arrow-up-down.pdf new file mode 100644 index 0000000000000000000000000000000000000000..34bc06e510810c1bf25816d90acad3497e127e1b GIT binary patch literal 4034 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 RFcVTTP${5*fU2(Ae*ntf`P~2j literal 0 HcmV?d00001 diff --git a/BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json b/BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json new file mode 100644 index 000000000..cba1e6c79 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "calendar.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/calendar.imageset/calendar.pdf b/BitkitWidget/Assets.xcassets/calendar.imageset/calendar.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4beb7cb14838b528bffa86ea3a2b9cc1292b30ee GIT binary patch literal 4026 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 literal 0 HcmV?d00001 diff --git a/BitkitWidget/Assets.xcassets/clock.imageset/Contents.json b/BitkitWidget/Assets.xcassets/clock.imageset/Contents.json new file mode 100644 index 000000000..0397df2e7 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/clock.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "clock.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/clock.imageset/clock.pdf b/BitkitWidget/Assets.xcassets/clock.imageset/clock.pdf new file mode 100644 index 0000000000000000000000000000000000000000..bf69f0658c14e25b8944fa1e0f97af9e26797f85 GIT binary patch literal 3922 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 GY5xblm%ZEo literal 0 HcmV?d00001 diff --git a/BitkitWidget/Assets.xcassets/coins.imageset/Contents.json b/BitkitWidget/Assets.xcassets/coins.imageset/Contents.json new file mode 100644 index 000000000..7ca0a2a25 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/coins.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "coins.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/coins.imageset/coins.pdf b/BitkitWidget/Assets.xcassets/coins.imageset/coins.pdf new file mode 100644 index 0000000000000000000000000000000000000000..88f4fd8183a883cf86ceebeee4217e1d685d65c7 GIT binary patch literal 11668 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 D0#_a3 literal 0 HcmV?d00001 diff --git a/BitkitWidget/Assets.xcassets/globe.imageset/Contents.json b/BitkitWidget/Assets.xcassets/globe.imageset/Contents.json new file mode 100644 index 000000000..fbc26e51a --- /dev/null +++ b/BitkitWidget/Assets.xcassets/globe.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "globe.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/globe.imageset/globe.pdf b/BitkitWidget/Assets.xcassets/globe.imageset/globe.pdf new file mode 100644 index 0000000000000000000000000000000000000000..08a6eddc11869f6e3dd38116f8f0d15382a8048f GIT binary patch literal 4496 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 BlocksWidgetEntry { + Self.mockEntry + } + + func getSnapshot(in context: Context, completion: @escaping (BlocksWidgetEntry) -> Void) { + let options = BlocksHomeScreenWidgetOptionsStore.load() + + if context.isPreview { + completion(BlocksWidgetEntry( + date: Self.mockEntry.date, + block: Self.mockBlock, + options: options, + showsError: false + )) + return + } + + let cached = BlocksWidgetService.cachedLatest() + completion(BlocksWidgetEntry( + date: Date(), + block: cached, + options: options, + showsError: false + )) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let options = BlocksHomeScreenWidgetOptionsStore.load() + + Task { + let entry: BlocksWidgetEntry + do { + let fresh = try await BlocksWidgetService.fetchFreshLatest() + entry = BlocksWidgetEntry(date: Date(), block: fresh, options: options, showsError: false) + } catch { + if let cached = BlocksWidgetService.cachedLatest() { + entry = BlocksWidgetEntry(date: Date(), block: cached, options: options, showsError: false) + } else { + entry = BlocksWidgetEntry(date: Date(), block: nil, options: options, showsError: true) + } + } + + let nextRefresh = Date().addingTimeInterval(BlocksWidgetEntryBuilder.refreshInterval) + completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + } + } +} + +// MARK: - View + +struct BlocksHomeScreenWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode + + var entry: BlocksWidgetProvider.Entry + + var body: some View { + content + .containerBackground(for: .widget) { backgroundView } + } + + @ViewBuilder + private var content: some View { + if entry.showsError { + errorView + } else if let block = entry.block { + switch widgetFamily { + case .systemSmall: + compactLayout(block: block) + default: + wideLayout(block: block, fields: entry.options.enabledFields) + } + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + + // MARK: - Layouts + + /// Compact (`.systemSmall`): icon + value rows for the selected fields. + private func compactLayout(block: CachedBlock) -> some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(entry.options.enabledFields, id: \.self) { field in + HStack(alignment: .center, spacing: 8) { + iconImage(field: field) + Text(field.value(from: block)) + .font(Fonts.semiBold(size: 15)) + .foregroundColor(titleTextColor) + .lineLimit(1) + .truncationMode(.middle) + .widgetAccentable() + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + /// Wide layout (`.systemMedium`): icon + label + value rows for the selected fields. + private func wideLayout(block: CachedBlock, fields: [BlocksWidgetField]) -> some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(fields, id: \.self) { field in + HStack(alignment: .center, spacing: 8) { + iconImage(field: field) + Text(field.label) + .font(Fonts.regular(size: 17)) + .foregroundColor(labelTextColor) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + Text(field.value(from: block)) + .font(Fonts.semiBold(size: 17)) + .foregroundColor(titleTextColor) + .lineLimit(1) + .truncationMode(.middle) + .widgetAccentable() + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func iconImage(field: BlocksWidgetField) -> some View { + Image(field.iconName) + .resizable() + .renderingMode(.template) + .foregroundColor(iconColor) + .frame(width: 20, height: 20) + .widgetAccentable() + } + + private var errorView: some View { + Text("Couldn’t load blocks data.") + .font(Fonts.regular(size: 13)) + .foregroundColor(labelTextColor) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Colors + + private var backgroundView: some View { + widgetRenderingMode == .fullColor ? Color.gray6 : Color.clear + } + + private var titleTextColor: Color { + widgetRenderingMode == .fullColor ? .white : .primary + } + + private var labelTextColor: Color { + widgetRenderingMode == .fullColor ? .white.opacity(0.8) : .secondary + } + + private var iconColor: Color { + widgetRenderingMode == .fullColor ? .brandAccent : .primary + } +} + +// MARK: - Widget Configuration + +struct BitkitBlocksWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration( + kind: BlocksHomeScreenWidgetOptionsStore.blocksHomeScreenWidgetKind, + provider: BlocksWidgetProvider() + ) { entry in + BlocksHomeScreenWidgetEntryView(entry: entry) + } + .configurationDisplayName("Bitcoin Blocks") + .description("Latest mined Bitcoin block, mirroring the in-app blocks widget.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} diff --git a/BitkitWidget/BlocksWidgetService.swift b/BitkitWidget/BlocksWidgetService.swift new file mode 100644 index 000000000..59b5a1dbe --- /dev/null +++ b/BitkitWidget/BlocksWidgetService.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Slim Bitcoin Blocks fetcher used inside the WidgetKit extension. +/// +/// Reads the latest `CachedBlock` from the App Group (written by the main app's `BlocksService`) +/// and falls back to a direct mempool.space fetch when the cache is empty. The cache itself is +/// owned by the main app; this service intentionally does not write back to it. +enum BlocksWidgetService { + enum FetchError: Error { + case invalidURL + case unexpectedResponse + case missingData + } + + private static let baseUrl = "https://mempool.space/api" + + static func cachedLatest() -> CachedBlock? { + BlocksWidgetCache.loadLatest() + } + + static func fetchFreshLatest() async throws -> CachedBlock { + guard let tipUrl = URL(string: "\(baseUrl)/blocks/tip/hash") else { + throw FetchError.invalidURL + } + + let (hashData, hashResponse) = try await URLSession.shared.data(from: tipUrl) + guard let httpResponse = hashResponse as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw FetchError.unexpectedResponse + } + + let hash = String(data: hashData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + guard let blockUrl = URL(string: "\(baseUrl)/v1/block/\(hash)") else { + throw FetchError.invalidURL + } + + let (blockData, blockResponse) = try await URLSession.shared.data(from: blockUrl) + guard let httpBlockResponse = blockResponse as? HTTPURLResponse, httpBlockResponse.statusCode == 200 else { + throw FetchError.unexpectedResponse + } + + let info = try JSONDecoder().decode(WireBlock.self, from: blockData) + return Self.format(info) + } + + private static func format(_ info: WireBlock) -> CachedBlock { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = Locale.current + + let sizeKb = Double(info.size) / 1024 + + let timeFormatter = DateFormatter() + timeFormatter.dateStyle = .none + timeFormatter.timeStyle = .medium + + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .short + dateFormatter.timeStyle = .none + + let date = Date(timeIntervalSince1970: TimeInterval(info.timestamp)) + + let formattedHeight = formatter.string(from: NSNumber(value: info.height)) ?? "\(info.height)" + let formattedSize = "\(formatter.string(from: NSNumber(value: Int(sizeKb))) ?? "\(Int(sizeKb))") KB" + let formattedTransactions = formatter.string(from: NSNumber(value: info.txCount)) ?? "\(info.txCount)" + let totalFees = info.extras?.totalFees ?? 0 + let formattedFees = formatter.string(from: NSNumber(value: totalFees)) ?? "\(totalFees)" + + return CachedBlock( + height: formattedHeight, + time: timeFormatter.string(from: date), + date: dateFormatter.string(from: date), + transactionCount: formattedTransactions, + size: formattedSize, + fees: formattedFees + ) + } +} + +// MARK: - Wire models + +/// Local mirror of the mempool `/api/v1/block/:hash` payload — kept private so the extension +/// stays small and decoupled from the main app's `BlockInfo`. +private struct WireBlock: Codable { + let id: String + let height: Int + let timestamp: Int + let txCount: Int + let size: Int + let weight: Int + let extras: WireExtras? + + enum CodingKeys: String, CodingKey { + case id + case height + case timestamp + case txCount = "tx_count" + case size + case weight + case extras + } +} + +private struct WireExtras: Codable { + let totalFees: Int? +} diff --git a/changelog.d/next/blocks-widget-v61.added.md b/changelog.d/next/blocks-widget-v61.added.md new file mode 100644 index 000000000..f4c8ddcf4 --- /dev/null +++ b/changelog.d/next/blocks-widget-v61.added.md @@ -0,0 +1 @@ +Bitcoin Blocks home-screen widget and v61 in-app redesign. From bc30a89e3303162d9b3b9634411a87a175e1c2df Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 18 May 2026 13:52:53 -0300 Subject: [PATCH 60/60] fix: restore string --- .gitignore | 1 + Bitkit/Resources/Localization/en.lproj/Localizable.strings | 1 + 2 files changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 68d7db447..54e0bde72 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ buildServer.json # AIs .ai/ .claude/*.local* +.claude/scheduled_tasks.lock diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index cb61ee4b9..bb9175fbe 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1384,6 +1384,7 @@ "widgets__widget__edit" = "Widget Feed"; "widgets__widget__edit_default" = "Default"; "widgets__widget__edit_custom" = "Custom"; +"widgets__widget__edit_description" = "Please choose the fields you would like to see in the {name} widget."; "widgets__widget__source" = "Source"; "widgets__add" = "Add Widget"; "widgets__list__button" = "Enable In Settings";