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.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index df861a506..e7c80f00c 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -12,6 +12,10 @@ 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, ); }; }; + 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 */; }; @@ -25,6 +29,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 4A319B602E8F24F4002B9AC9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 96FE1F592C2DE6AA006D0C8B /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4A319B4E2E8F24F2002B9AC9; + remoteInfo = BitkitWidgetExtension; + }; 961058E12C355B5500E1F1D8 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 96FE1F592C2DE6AA006D0C8B /* Project object */; @@ -56,6 +67,7 @@ dstSubfolderSpec = 13; files = ( 961058E32C355B5500E1F1D8 /* BitkitNotification.appex in Embed Foundation Extensions */, + 4A319B622E8F24F4002B9AC9 /* BitkitWidgetExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -64,6 +76,25 @@ /* 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 = ""; }; + 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; }; @@ -71,6 +102,13 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 4A319B662E8F24F4002B9AC9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */; + }; 96A44F3D2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -86,6 +124,7 @@ "Extensions/LDKNode+AddressType.swift", Extensions/PaymentDetails.swift, Models/BlocktankNotificationType.swift, + Models/BlocksWidgetOptions.swift, Models/LnPeer.swift, Models/PubkyPublicKeyFormat.swift, Models/Toast.swift, @@ -141,16 +180,53 @@ ); target = 961058DB2C355B5500E1F1D8 /* BitkitNotification */; }; + 4A319B672E8F24F5002B9AC9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Fonts/InterTight-Black.ttf, + Fonts/InterTight-Bold.ttf, + Fonts/InterTight-ExtraBold.ttf, + Fonts/InterTight-Medium.ttf, + Fonts/InterTight-Regular.ttf, + Constants/WidgetEnv.swift, + Fonts/InterTight-SemiBold.ttf, + Models/BlocksWidgetData.swift, + Models/BlocksWidgetFields.swift, + Models/BlocksWidgetOptions.swift, + Models/BitcoinFacts.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, + Styles/Fonts.swift, + Styles/TextStyle.swift, + ); + target = 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 96A44E912CEF5EA700FBACFF /* Bitkit */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (96A44F3D2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 96A44F3E2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 96A44F3F2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Bitkit; sourceTree = ""; }; + 4A319B542E8F24F2002B9AC9 /* BitkitWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4A319B662E8F24F4002B9AC9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = BitkitWidget; sourceTree = ""; }; + 96A44E912CEF5EA700FBACFF /* Bitkit */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (96A44F3D2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 96A44F3E2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 96A44F3F2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4A319B672E8F24F5002B9AC9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Bitkit; sourceTree = ""; }; 96A44F4A2CEF5F4B00FBACFF /* BitkitTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = BitkitTests; sourceTree = ""; }; 96A44F562CEF5F5400FBACFF /* BitkitUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = BitkitUITests; sourceTree = ""; }; 96A44F5C2CEF5F5800FBACFF /* BitkitNotification */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (96A44F5E2CEF5F5800FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = BitkitNotification; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 4A319B4C2E8F24F2002B9AC9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4A319B532E8F24F2002B9AC9 /* SwiftUI.framework in Frameworks */, + 4A319B512E8F24F2002B9AC9 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 961058D92C355B5500E1F1D8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -200,6 +276,8 @@ isa = PBXGroup; children = ( 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */, + 4A319B502E8F24F2002B9AC9 /* WidgetKit.framework */, + 4A319B522E8F24F2002B9AC9 /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -207,10 +285,13 @@ 96FE1F582C2DE6AA006D0C8B = { isa = PBXGroup; children = ( + 4A319B6E2E8F25F6002B9AC9 /* BitkitWidgetExtension.entitlements */, 96A44E912CEF5EA700FBACFF /* Bitkit */, 96A44F4A2CEF5F4B00FBACFF /* BitkitTests */, 96A44F562CEF5F5400FBACFF /* BitkitUITests */, 96A44F5C2CEF5F5800FBACFF /* BitkitNotification */, + 4A319B542E8F24F2002B9AC9 /* BitkitWidget */, + 4A319B712E8F2600002B9AC9 /* Localizable.strings */, 96FE1F622C2DE6AA006D0C8B /* Products */, 961058EC2C35798C00E1F1D8 /* Frameworks */, ); @@ -223,6 +304,7 @@ 96FE1F722C2DE6AC006D0C8B /* BitkitTests.xctest */, 96FE1F7C2C2DE6AC006D0C8B /* BitkitUITests.xctest */, 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */, + 4A319B4F2E8F24F2002B9AC9 /* BitkitWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -230,6 +312,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4A319B652E8F24F4002B9AC9 /* Build configuration list for PBXNativeTarget "BitkitWidgetExtension" */; + buildPhases = ( + 4A319B4B2E8F24F2002B9AC9 /* Sources */, + 4A319B4C2E8F24F2002B9AC9 /* Frameworks */, + 4A319B4D2E8F24F2002B9AC9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 4A319B542E8F24F2002B9AC9 /* BitkitWidget */, + ); + name = BitkitWidgetExtension; + packageProductDependencies = ( + ); + productName = BitkitWidgetExtension; + productReference = 4A319B4F2E8F24F2002B9AC9 /* BitkitWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; 961058DB2C355B5500E1F1D8 /* BitkitNotification */ = { isa = PBXNativeTarget; buildConfigurationList = 961058E42C355B5500E1F1D8 /* Build configuration list for PBXNativeTarget "BitkitNotification" */; @@ -267,6 +371,7 @@ ); dependencies = ( 961058E22C355B5500E1F1D8 /* PBXTargetDependency */, + 4A319B612E8F24F4002B9AC9 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 96A44E912CEF5EA700FBACFF /* Bitkit */, @@ -337,9 +442,12 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1540; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1540; TargetAttributes = { + 4A319B4E2E8F24F2002B9AC9 = { + CreatedOnToolsVersion = 26.0; + }; 961058DB2C355B5500E1F1D8 = { CreatedOnToolsVersion = 15.4; }; @@ -396,11 +504,20 @@ 96FE1F712C2DE6AC006D0C8B /* BitkitTests */, 96FE1F7B2C2DE6AC006D0C8B /* BitkitUITests */, 961058DB2C355B5500E1F1D8 /* BitkitNotification */, + 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 4A319B4D2E8F24F2002B9AC9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4A319B702E8F2600002B9AC9 /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 961058DA2C355B5500E1F1D8 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -431,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; @@ -455,6 +597,13 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 4A319B4B2E8F24F2002B9AC9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 961058D82C355B5500E1F1D8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -486,6 +635,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 4A319B612E8F24F4002B9AC9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */; + targetProxy = 4A319B602E8F24F4002B9AC9 /* PBXContainerItemProxy */; + }; 961058E22C355B5500E1F1D8 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 961058DB2C355B5500E1F1D8 /* BitkitNotification */; @@ -504,6 +658,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 = 187; + DEVELOPMENT_TEAM = KYH47R284B; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BitkitWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BitkitWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 2.2.1; + 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 = 187; + DEVELOPMENT_TEAM = KYH47R284B; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BitkitWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BitkitWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 2.2.1; + PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit.widget"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 961058E52C355B5500E1F1D8 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -854,6 +1075,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/Assets.xcassets/icons/bitcoin.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/bitcoin.imageset/Contents.json new file mode 100644 index 000000000..8a6583348 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/bitcoin.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bitcoin.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 000000000..caaae04e5 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/bitcoin.imageset/bitcoin.pdf differ 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/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/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/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index aeb8a6a01..eb75c65fc 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -1,28 +1,14 @@ 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 +/// 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, @@ -39,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") - } - } - } - .onAppear { - fetchPriceData() - } - .onChange(of: options.selectedPairs) { - fetchPriceData() + content } - .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 } @@ -134,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/Constants/WidgetEnv.swift b/Bitkit/Constants/WidgetEnv.swift new file mode 100644 index 000000000..19eb7e1ae --- /dev/null +++ b/Bitkit/Constants/WidgetEnv.swift @@ -0,0 +1,12 @@ +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" + 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 a787d4e9f..023305007 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) @@ -431,7 +437,19 @@ struct MainNavView: View { // Widgets case .widgetsIntro: WidgetsIntroView() case .widgetsList: WidgetsListView() - case let .widgetDetail(widgetType): WidgetDetailView(id: widgetType) + case let .widgetDetail(widgetType): + switch widgetType { + case .price: + PriceWidgetPreviewView() + case .news: + NewsWidgetPreviewView() + case .blocks: + BlocksWidgetPreviewView() + case .facts: + FactsWidgetPreviewView() + default: + WidgetDetailView(id: widgetType) + } case let .widgetEdit(widgetType): WidgetEditView(id: widgetType) // Settings 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/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/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/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..f4f90dcb0 --- /dev/null +++ b/Bitkit/Models/PriceWidgetOptions.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Options for configuring the in-app and home-screen price widgets (shared via App Group). +/// +struct PriceWidgetOptions: Codable, Equatable { + 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/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index dc51a52c1..bb9175fbe 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1384,7 +1384,7 @@ "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__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"; @@ -1393,12 +1393,24 @@ "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"; +"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..c3a2dd786 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -270,23 +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 -} - // MARK: - RN Migration Keys enum RNKeychainKey { @@ -1962,34 +1945,18 @@ 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 } } - 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/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/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/Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift new file mode 100644 index 000000000..b812a5bc6 --- /dev/null +++ b/Bitkit/Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift @@ -0,0 +1,36 @@ +import Foundation +import WidgetKit + +/// Mirrors in-app price widget options into the App Group so the WidgetKit extension can read them, +/// and centralizes the WidgetKit reload trigger for the price home-screen widget. +enum PriceHomeScreenWidgetOptionsStore { + /// WidgetKit `kind` for the home-screen price widget (must match `BitkitPriceWidget`). + static let priceHomeScreenWidgetKind = "BitkitPriceWidget" + + private static let suiteName = "group.bitkit" + private static let key = "home_screen_price_widget_options_v1" + + static func save(_ options: PriceWidgetOptions) { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = try? JSONEncoder().encode(options) + else { return } + defaults.set(data, forKey: key) + } + + static func load() -> PriceWidgetOptions { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = defaults.data(forKey: key), + let options = try? JSONDecoder().decode(PriceWidgetOptions.self, from: data) + else { + return PriceWidgetOptions() + } + return options + } + + /// Call after updating options or cache so the home-screen widget timeline refreshes. + /// No-op when running inside the widget extension itself (`appex`). + static func reloadHomeScreenWidgetIfNeeded() { + guard Bundle.main.bundleURL.pathExtension != "appex" else { return } + WidgetCenter.shared.reloadTimelines(ofKind: priceHomeScreenWidgetKind) + } +} diff --git a/Bitkit/Services/Widgets/PriceService.swift b/Bitkit/Services/Widgets/PriceService.swift index 0569c0e77..411d0f22e 100644 --- a/Bitkit/Services/Widgets/PriceService.swift +++ b/Bitkit/Services/Widgets/PriceService.swift @@ -2,13 +2,6 @@ import Foundation // MARK: - Data Models -public struct TradingPair { - public let name: String - public let base: String - public let quote: String - public let symbol: String -} - struct PriceResponse: Codable { let price: Double let timestamp: Double @@ -38,25 +31,6 @@ struct CandleResponse: Codable { let volume: Double } -struct PriceChange { - let isPositive: Bool - let formatted: String -} - -struct PriceData { - let name: String - let change: PriceChange - let price: String - let pastValues: [Double] -} - -enum GraphPeriod: String, CaseIterable, Codable { - case oneDay = "1D" - case oneWeek = "1W" - case oneMonth = "1M" - case oneYear = "1Y" -} - enum PriceServiceError: Error { case invalidURL case invalidPair @@ -65,66 +39,11 @@ enum PriceServiceError: Error { case noPriceDataAvailable } -// MARK: - Trading Pairs Constants - -public let tradingPairs: [TradingPair] = [ - TradingPair(name: "BTC/USD", base: "BTC", quote: "USD", symbol: "$"), - TradingPair(name: "BTC/EUR", base: "BTC", quote: "EUR", symbol: "€"), - TradingPair(name: "BTC/GBP", base: "BTC", quote: "GBP", symbol: "£"), - TradingPair(name: "BTC/JPY", base: "BTC", quote: "JPY", symbol: "¥"), -] - -/// Convenience array for just the pair names -public let tradingPairNames: [String] = tradingPairs.map(\.name) - -// MARK: - Helper Models - -private struct CachedPriceData: Codable { - let name: String - let changeIsPositive: Bool - let changeFormatted: String - let price: String - let pastValues: [Double] -} - -// MARK: - Caching System - -class PriceWidgetCache { - static let shared = PriceWidgetCache() - private let userDefaults = UserDefaults.standard - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() - - private init() {} - - func set(_ value: some Codable, forKey key: String) { - do { - let data = try encoder.encode(value) - userDefaults.set(data, forKey: "price_widget_cache_\(key)") - } catch { - print("Failed to cache price data for key \(key): \(error)") - } - } - - func get(_ type: T.Type, forKey key: String) -> T? { - guard let data = userDefaults.data(forKey: "price_widget_cache_\(key)") else { - return nil - } - - do { - return try decoder.decode(type, from: data) - } catch { - print("Failed to decode cached price data for key \(key): \(error)") - return nil - } - } -} - // MARK: - Price Service class PriceService { static let shared = PriceService() - private let baseURL = "https://feeds.synonym.to/price-feed/api" + private let baseURL = WidgetEnv.priceFeedBaseUrl private init() {} @@ -190,21 +109,7 @@ class PriceService { } private func getCachedData(pairs: [String], period: GraphPeriod) -> [PriceData]? { - let cache = PriceWidgetCache.shared - let cachedItems = pairs.compactMap { pairName in - cache.get(CachedPriceData.self, forKey: "\(pairName)_\(period.rawValue)") - } - - guard cachedItems.count == pairs.count else { return nil } - - return cachedItems.map { cached in - PriceData( - name: cached.name, - change: PriceChange(isPositive: cached.changeIsPositive, formatted: cached.changeFormatted), - price: cached.price, - pastValues: cached.pastValues - ) - } + PriceWidgetCache.loadAll(pairs: pairs, period: period) } private func fetchPairData(pairName: String, period: GraphPeriod) async throws -> PriceData { @@ -288,15 +193,8 @@ class PriceService { return "\(pair.symbol) \(formatted)" } - private func cacheData(pairName: String, period: GraphPeriod, data: PriceData) { - let cacheKey = "\(pairName)_\(period.rawValue)" - let cachedData = CachedPriceData( - name: data.name, - changeIsPositive: data.change.isPositive, - changeFormatted: data.change.formatted, - price: data.price, - pastValues: data.pastValues - ) - PriceWidgetCache.shared.set(cachedData, forKey: cacheKey) + private func cacheData(pairName _: String, period: GraphPeriod, data: PriceData) { + PriceWidgetCache.save(data, period: period) + PriceHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } } diff --git a/Bitkit/Styles/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/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/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 889a76095..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]? @@ -32,7 +31,7 @@ enum WidgetsBackupConverter { "showDate": options.date, "showTransactions": options.transactionCount, "showSize": options.size, - "showSource": options.showSource, + "showFees": options.fees, ] } case .news: @@ -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 = [ @@ -59,17 +52,14 @@ 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: + case .calculator, .suggestions, .facts: break } } @@ -78,7 +68,6 @@ enum WidgetsBackupConverter { return [ "widgets": widgetsArray, "headlinePreferences": newsPreferences ?? getDefaultNewsPreferences(), - "factsPreferences": factsPreferences ?? getDefaultFactsPreferences(), "blocksPreferences": blocksPreferences ?? getDefaultBlocksPreferences(), "weatherPreferences": weatherPreferences ?? getDefaultWeatherPreferences(), "pricePreferences": pricePreferences ?? getDefaultPricePreferences(), @@ -128,13 +117,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) } @@ -147,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( @@ -166,25 +144,24 @@ 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) } - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: break } @@ -205,7 +182,7 @@ enum WidgetsBackupConverter { "showDate": defaults.date, "showTransactions": defaults.transactionCount, "showSize": defaults.size, - "showSource": defaults.showSource, + "showFees": defaults.fees, ] } @@ -217,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 [ @@ -236,14 +206,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/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/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 cd954ff90..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), @@ -188,9 +182,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 +249,18 @@ class WidgetsViewModel: ObservableObject { } persistSavedWidgets() + + if type == .price, let priceOptions = options as? PriceWidgetOptions { + syncPriceOptionsToHomeScreenWidget(priceOptions) + } + + 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)") } @@ -262,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() @@ -311,4 +314,28 @@ class WidgetsViewModel: ObservableObject { print("Failed to persist widgets: \(error)") } } + + /// 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() + } + + /// 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() + } + + /// 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/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/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 new file mode 100644 index 000000000..1b95fdc66 --- /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.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 { + PriceWidgetPreviewView() + .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/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index 423477dbe..1d692fc86 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 .staticItem, .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 .sectionHeader: + EmptyView() } } } diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index 3820ab6f0..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 } } @@ -34,19 +33,21 @@ 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 true + return blocksOptions.height + || blocksOptions.time + || blocksOptions.date + || blocksOptions.transactionCount + || blocksOptions.size + || blocksOptions.fees + case .news: + return newsOptions.showTitle || newsOptions.showSource || newsOptions.showDate 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 has options, check if at least one trading pair is selected - return !priceOptions.selectedPairs.isEmpty - case .calculator, .suggestions: + // Price widget always has a selected pair (single-select). + return true + case .calculator, .suggestions, .facts: return false } } @@ -56,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 @@ -68,7 +66,7 @@ class WidgetEditLogic: ObservableObject { case .price: let defaultOptions = PriceWidgetOptions() return priceOptions != defaultOptions - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: return false } } @@ -77,38 +75,29 @@ 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: 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() - default: - break - } - case .facts: - switch item.key { - case "showSource": - factsOptions.showSource.toggle() + case "fees": + guard canToggleBlockOption(blocksOptions.fees) else { break } + blocksOptions.fees.toggle() default: break } @@ -138,14 +127,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": + priceOptions.selectedPair = item.key case "1D": priceOptions.selectedPeriod = .oneDay case "1W": @@ -154,38 +137,41 @@ class WidgetEditLogic: ObservableObject { priceOptions.selectedPeriod = .oneMonth case "1Y": priceOptions.selectedPeriod = .oneYear - case "showSource": - priceOptions.showSource.toggle() default: break } - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: break } onStateChange?() } - private func toggleTradingPair(_ pairName: String) { - if priceOptions.selectedPairs.contains(pairName) { - priceOptions.selectedPairs.removeAll { $0 == pairName } - } else { - priceOptions.selectedPairs.append(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, + ].filter { $0 }.count } func loadCurrentOptions() { 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 } } @@ -194,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?() @@ -212,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 575f500b6..cffbb22cd 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 { @@ -51,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( - 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(sectionHeaderItem(key: "blocks_data_header", title: t("widgets__blocks__data_header"))) - items.append( - WidgetEditItem( - key: "weight", - type: .toggleItem, - title: "Weight", - value: "3.45 MWU", - 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: "102.45 T", - 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: "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054", - 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", + key: field.rawValue, 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) ) ) } @@ -257,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, @@ -290,65 +117,67 @@ enum WidgetEditItemFactory { ) -> [WidgetEditItem] { var items: [WidgetEditItem] = [] + items.append(sectionHeaderItem(key: "news_content_header", title: t("widgets__news__content_header"))) + if let data = newsViewModel.widgetData { items.append( WidgetEditItem( - key: "showDate", + key: "showTitle", type: .toggleItem, - titleView: AnyView(BodyMText(data.timeAgo, textColor: .textPrimary)), + titleView: AnyView(TitleText(data.title)), valueView: nil, - isChecked: newsOptions.showDate + isChecked: newsOptions.showTitle ) ) items.append( WidgetEditItem( - key: "showTitle", - type: .staticItem, - titleView: AnyView(TitleText(data.title)), + key: "showSource", + type: .toggleItem, + titleView: AnyView(BodySSBText(data.publisher, textColor: .brandAccent)), valueView: nil, - isChecked: true // Static items are always shown + isChecked: newsOptions.showSource ) ) items.append( WidgetEditItem( - key: "showSource", + key: "showDate", type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText(data.publisher, textColor: .textSecondary)), - isChecked: newsOptions.showSource + titleView: AnyView(BodySSBText(data.timeAgo, textColor: .textSecondary)), + valueView: nil, + isChecked: newsOptions.showDate ) ) } else { // Fallback when no data is available items.append( WidgetEditItem( - key: "showDate", + key: "showTitle", type: .toggleItem, - titleView: AnyView(BodyMText("13 hours ago", textColor: .textPrimary)), + titleView: AnyView(TitleText("How Bitcoin changed El Salvador in more ways...")), valueView: nil, - isChecked: newsOptions.showDate + isChecked: newsOptions.showTitle ) ) items.append( WidgetEditItem( - key: "showTitle", - type: .staticItem, - titleView: AnyView(TitleText("Exodus Launches XO Pay, An In-App Bitcoin And Crypto Purchase Solution")), + key: "showSource", + type: .toggleItem, + titleView: AnyView(BodySSBText("bitcoinmagazine.com", textColor: .brandAccent)), valueView: nil, - isChecked: true // Static items are always shown + isChecked: newsOptions.showSource ) ) items.append( WidgetEditItem( - key: "showSource", + key: "showDate", type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("Bitcoin Magazine", textColor: .textSecondary)), - isChecked: newsOptions.showSource + titleView: AnyView(BodySSBText("1 min ago", textColor: .textSecondary)), + valueView: nil, + isChecked: newsOptions.showDate ) ) } @@ -357,68 +186,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, @@ -516,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 @@ -529,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 8cfc81174..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 @@ -55,17 +52,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: t("widgets__widget__edit")) - .padding(.bottom, 16) - - BodyMText( - t("widgets__widget__edit_description", variables: ["name": widget.name]), - textColor: .textSecondary + NavigationBar( + 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 @@ -73,7 +81,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 +115,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/Assets.xcassets/AccentColor.colorset/Contents.json b/BitkitWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitkitWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/BitkitWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitkitWidget/Assets.xcassets/Contents.json b/BitkitWidget/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/BitkitWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitkitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/BitkitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitkitWidget/Assets.xcassets/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 000000000..34bc06e51 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/arrow-up-down.pdf differ 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 000000000..8085e5f48 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/bitcoin.imageset/bitcoin.pdf differ 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 000000000..4beb7cb14 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/calendar.imageset/calendar.pdf differ 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 000000000..bf69f0658 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/clock.imageset/clock.pdf differ 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 000000000..88f4fd818 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/coins.imageset/coins.pdf differ diff --git a/BitkitWidget/Assets.xcassets/cube.imageset/Contents.json b/BitkitWidget/Assets.xcassets/cube.imageset/Contents.json new file mode 100644 index 000000000..9c36c08b7 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/cube.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "cube.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/cube.imageset/cube.pdf b/BitkitWidget/Assets.xcassets/cube.imageset/cube.pdf new file mode 100644 index 000000000..75afbf287 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/cube.imageset/cube.pdf differ diff --git a/BitkitWidget/Assets.xcassets/file-text.imageset/Contents.json b/BitkitWidget/Assets.xcassets/file-text.imageset/Contents.json new file mode 100644 index 000000000..5064d8d33 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/file-text.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "file-text.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/file-text.imageset/file-text.pdf b/BitkitWidget/Assets.xcassets/file-text.imageset/file-text.pdf new file mode 100644 index 000000000..3d68de81a Binary files /dev/null and b/BitkitWidget/Assets.xcassets/file-text.imageset/file-text.pdf differ 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 000000000..08a6eddc1 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/globe.imageset/globe.pdf differ diff --git a/BitkitWidget/BitkitWidget.entitlements b/BitkitWidget/BitkitWidget.entitlements new file mode 100644 index 000000000..f5cc9b7c2 --- /dev/null +++ b/BitkitWidget/BitkitWidget.entitlements @@ -0,0 +1,11 @@ + + + + + com.apple.security.application-groups + + group.bitkit + + + + diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift new file mode 100644 index 000000000..dfd5eb8f9 --- /dev/null +++ b/BitkitWidget/BitkitWidget.swift @@ -0,0 +1,12 @@ +import SwiftUI +import WidgetKit + +@main +struct BitkitWidgetBundle: WidgetBundle { + var body: some Widget { + BitkitPriceWidget() + BitkitNewsWidget() + BitkitBlocksWidget() + BitkitFactsWidget() + } +} diff --git a/BitkitWidget/BlocksHomeScreenWidget.swift b/BitkitWidget/BlocksHomeScreenWidget.swift new file mode 100644 index 000000000..6f3c348a9 --- /dev/null +++ b/BitkitWidget/BlocksHomeScreenWidget.swift @@ -0,0 +1,210 @@ +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) + 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/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]) + } +} diff --git a/BitkitWidget/Info.plist b/BitkitWidget/Info.plist new file mode 100644 index 000000000..b12fe2e15 --- /dev/null +++ b/BitkitWidget/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Bitcoin Facts + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + UIAppFonts + + InterTight-Black.ttf + InterTight-Bold.ttf + InterTight-ExtraBold.ttf + InterTight-Medium.ttf + InterTight-Regular.ttf + InterTight-SemiBold.ttf + + + diff --git a/BitkitWidget/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/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift new file mode 100644 index 000000000..f30057c11 --- /dev/null +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -0,0 +1,288 @@ +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 { + 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 { + 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 + } + + 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.selectedPair], + period: options.selectedPeriod + ) + entry = PriceWidgetEntry(date: Date(), prices: fresh, options: options, showsError: false) + } catch { + let cached = PriceWidgetService.cachedPrices(pairs: [options.selectedPair], 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 { + content + .containerBackground(for: .widget) { backgroundView } + } + + @ViewBuilder + private var content: some View { + if entry.showsError { + errorView + } else if let primary = primaryPrice { + switch widgetFamily { + case .systemSmall: + compactLayout(data: primary) + default: + 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: - Compact (small widget — 163×192) + + 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) + } + + priceText(data.price, size: 22) + + Text(data.change.formatted) + .font(Fonts.semiBold(size: 15)) + .foregroundColor(changeColor(isPositive: data.change.isPositive)) + .lineLimit(1) + .widgetAccentable() + } + + Spacer(minLength: 8) + + chart(values: data.pastValues, isPositive: data.change.isPositive, idealHeight: 64) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Wide (medium widget — 343×152) + + 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) + } + + // 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 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 { + BodySText("Couldn’t load price.", textColor: secondaryTextColor) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // 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 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 + } + + var body: some View { + Chart { + ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in + 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) + } +} + +// 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]) + } +} diff --git a/BitkitWidget/PriceWidgetService.swift b/BitkitWidget/PriceWidgetService.swift new file mode 100644 index 000000000..c07ba499f --- /dev/null +++ b/BitkitWidget/PriceWidgetService.swift @@ -0,0 +1,131 @@ +import Foundation + +/// Slim price fetcher used inside the WidgetKit extension. +/// +/// Reads cached `PriceData` from the App Group (written by the main app's `PriceService`) +/// and falls back to a direct network fetch when no cache is available or when explicitly +/// asked to refresh. The cache itself is owned by the main app — this service intentionally +/// does not write back to it, to keep the extension's footprint minimal. +enum PriceWidgetService { + enum FetchError: Error { + case invalidURL + case invalidPair + case noPriceDataAvailable + } + + // MARK: - Cache + + static func cachedPrices(pairs: [String], period: GraphPeriod) -> [PriceData]? { + PriceWidgetCache.loadAll(pairs: pairs, period: period) + } + + // MARK: - Fresh Fetch + + static func fetchFreshPrices(pairs: [String], period: GraphPeriod) async throws -> [PriceData] { + let results = await withTaskGroup(of: (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: [(Int, PriceData)] = [] + for await (index, result) in group { + if let result { collected.append((index, result)) } + } + return collected.sorted { $0.0 < $1.0 }.map(\.1) + } + + guard !results.isEmpty else { throw FetchError.noPriceDataAvailable } + return results + } + + // MARK: - Per-pair pipeline + + private static func fetchPair(pairName: String, period: GraphPeriod) async throws -> PriceData { + guard let pair = tradingPairs.first(where: { $0.name == pairName }) else { + throw FetchError.invalidPair + } + + let ticker = "\(pair.base)\(pair.quote)" + let candles = try await fetchCandles(ticker: ticker, period: period) + let pastValues = candles.sorted(by: { $0.timestamp < $1.timestamp }).map(\.close) + + let latest = try await fetchLatestPrice(ticker: ticker) + let updated = Array(pastValues.dropLast()) + [latest] + + return PriceData( + name: pairName, + change: priceChange(from: updated), + price: formatPrice(pair: pair, price: latest), + pastValues: updated + ) + } + + private static func fetchLatestPrice(ticker: String) async throws -> Double { + guard let url = URL(string: "\(WidgetEnv.priceFeedBaseUrl)/price/\(ticker)/latest") else { + throw FetchError.invalidURL + } + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode(LatestPriceResponse.self, from: data).price + } + + private static func fetchCandles(ticker: String, period: GraphPeriod) async throws -> [Candle] { + guard let url = URL(string: "\(WidgetEnv.priceFeedBaseUrl)/price/\(ticker)/history/\(period.rawValue)") else { + throw FetchError.invalidURL + } + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode([Candle].self, from: data) + } + + private static func priceChange(from values: [Double]) -> PriceChange { + guard let first = values.first, let last = values.last, first != 0, values.count >= 2 else { + return PriceChange(isPositive: true, formatted: "+0%") + } + let change = last / first - 1 + let sign = change >= 0 ? "+" : "" + return PriceChange( + isPositive: change >= 0, + formatted: "\(sign)\(String(format: "%.2f", change * 100))%" + ) + } + + private static func formatPrice(pair: TradingPair, price: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 0 + let formatted = formatter.string(from: NSNumber(value: price)) ?? String(format: "%.0f", price) + return "\(pair.symbol) \(formatted)" + } +} + +// MARK: - Wire Models + +private struct LatestPriceResponse: Codable { + let price: Double + let timestamp: Double + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + timestamp = try container.decode(Double.self, forKey: .timestamp) + + // Server may serialize price as either string or number. + if let priceString = try? container.decode(String.self, forKey: .price), + let parsed = Double(priceString) + { + price = parsed + } else { + price = try container.decode(Double.self, forKey: .price) + } + } +} + +private struct Candle: Codable { + let timestamp: Double + let open: Double + let close: Double + let high: Double + let low: Double + let volume: Double +} diff --git a/BitkitWidgetExtension.entitlements b/BitkitWidgetExtension.entitlements new file mode 100644 index 000000000..4fca2ce32 --- /dev/null +++ b/BitkitWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.bitkit + + + diff --git a/changelog.d/next/538.added.md b/changelog.d/next/538.added.md new file mode 100644 index 000000000..a42bd08e6 --- /dev/null +++ b/changelog.d/next/538.added.md @@ -0,0 +1 @@ +Added a Bitcoin Price home-screen widget that mirrors the in-app price widget. 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. 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. 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.