diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index df861a506..15e22deb6 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -12,6 +12,9 @@ 18D65E022EB964BD00252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65E012EB964BD00252335 /* VssRustClientFfi */; }; 3D76260F4C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */; }; 3D7626104C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */; }; + 4A319B512E8F24F2002B9AC9 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4A319B502E8F24F2002B9AC9 /* WidgetKit.framework */; }; + 4A319B532E8F24F2002B9AC9 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4A319B522E8F24F2002B9AC9 /* SwiftUI.framework */; }; + 4A319B622E8F24F4002B9AC9 /* BitkitWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4A319B4F2E8F24F2002B9AC9 /* BitkitWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4AAB08CA2E1FE77600BA63DF /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4AAB08C92E1FE77600BA63DF /* Lottie */; }; 961058E32C355B5500E1F1D8 /* BitkitNotification.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 9613018B2C5022D700878183 /* LDKNode */; }; @@ -25,6 +28,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 4A319B602E8F24F4002B9AC9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 96FE1F592C2DE6AA006D0C8B /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4A319B4E2E8F24F2002B9AC9; + remoteInfo = BitkitWidgetExtension; + }; 961058E12C355B5500E1F1D8 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 96FE1F592C2DE6AA006D0C8B /* Project object */; @@ -56,6 +66,7 @@ dstSubfolderSpec = 13; files = ( 961058E32C355B5500E1F1D8 /* BitkitNotification.appex in Embed Foundation Extensions */, + 4A319B622E8F24F4002B9AC9 /* BitkitWidgetExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -64,6 +75,10 @@ /* Begin PBXFileReference section */ 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBluetooth.framework; path = System/Library/Frameworks/CoreBluetooth.framework; sourceTree = SDKROOT; }; + 4A319B4F2E8F24F2002B9AC9 /* BitkitWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BitkitWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 4A319B502E8F24F2002B9AC9 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 4A319B522E8F24F2002B9AC9 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 4A319B6E2E8F25F6002B9AC9 /* BitkitWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BitkitWidgetExtension.entitlements; sourceTree = ""; }; 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BitkitNotification.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 96FE1F612C2DE6AA006D0C8B /* Bitkit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bitkit.app; sourceTree = BUILT_PRODUCTS_DIR; }; 96FE1F722C2DE6AC006D0C8B /* BitkitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BitkitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -71,6 +86,13 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 4A319B662E8F24F4002B9AC9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */; + }; 96A44F3D2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -141,16 +163,48 @@ ); 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/NewsWidgetData.swift, + Models/NewsWidgetOptions.swift, + Models/PriceWidgetData.swift, + Models/PriceWidgetOptions.swift, + Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift, + Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift, + Styles/Colors.swift, + Styles/Fonts.swift, + 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 +254,8 @@ isa = PBXGroup; children = ( 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */, + 4A319B502E8F24F2002B9AC9 /* WidgetKit.framework */, + 4A319B522E8F24F2002B9AC9 /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -207,10 +263,12 @@ 96FE1F582C2DE6AA006D0C8B = { isa = PBXGroup; children = ( + 4A319B6E2E8F25F6002B9AC9 /* BitkitWidgetExtension.entitlements */, 96A44E912CEF5EA700FBACFF /* Bitkit */, 96A44F4A2CEF5F4B00FBACFF /* BitkitTests */, 96A44F562CEF5F5400FBACFF /* BitkitUITests */, 96A44F5C2CEF5F5800FBACFF /* BitkitNotification */, + 4A319B542E8F24F2002B9AC9 /* BitkitWidget */, 96FE1F622C2DE6AA006D0C8B /* Products */, 961058EC2C35798C00E1F1D8 /* Frameworks */, ); @@ -223,6 +281,7 @@ 96FE1F722C2DE6AC006D0C8B /* BitkitTests.xctest */, 96FE1F7C2C2DE6AC006D0C8B /* BitkitUITests.xctest */, 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */, + 4A319B4F2E8F24F2002B9AC9 /* BitkitWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -230,6 +289,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 +348,7 @@ ); dependencies = ( 961058E22C355B5500E1F1D8 /* PBXTargetDependency */, + 4A319B612E8F24F4002B9AC9 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 96A44E912CEF5EA700FBACFF /* Bitkit */, @@ -337,9 +419,12 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1540; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1540; TargetAttributes = { + 4A319B4E2E8F24F2002B9AC9 = { + CreatedOnToolsVersion = 26.0; + }; 961058DB2C355B5500E1F1D8 = { CreatedOnToolsVersion = 15.4; }; @@ -396,11 +481,19 @@ 96FE1F712C2DE6AC006D0C8B /* BitkitTests */, 96FE1F7B2C2DE6AC006D0C8B /* BitkitUITests */, 961058DB2C355B5500E1F1D8 /* BitkitNotification */, + 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 4A319B4D2E8F24F2002B9AC9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 961058DA2C355B5500E1F1D8 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -455,6 +548,13 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 4A319B4B2E8F24F2002B9AC9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 961058D82C355B5500E1F1D8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -486,6 +586,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 +609,73 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 4A319B632E8F24F4002B9AC9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 186; + DEVELOPMENT_TEAM = KYH47R284B; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BitkitWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BitkitWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 2.2.0; + PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit.widget"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4A319B642E8F24F4002B9AC9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 186; + DEVELOPMENT_TEAM = KYH47R284B; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BitkitWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BitkitWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 2.2.0; + PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit.widget"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 961058E52C355B5500E1F1D8 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -854,6 +1026,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 4A319B652E8F24F4002B9AC9 /* Build configuration list for PBXNativeTarget "BitkitWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4A319B632E8F24F4002B9AC9 /* Debug */, + 4A319B642E8F24F4002B9AC9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 961058E42C355B5500E1F1D8 /* Build configuration list for PBXNativeTarget "BitkitNotification" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Bitkit/Components/Widgets/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..ba6c2e3f4 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -246,6 +246,12 @@ struct MainNavView: View { Task { Logger.info("Received deeplink: \(url.absoluteString)") + // Web URLs from widgets (e.g. news article tap) bypass payment handling + if let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { + await UIApplication.shared.open(url) + return + } + if let callback = PubkyRingAuthCallback.parse(url: url) { let handlingResult = await pubkyProfile.handleAuthCallback(callback) @@ -431,7 +437,15 @@ 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() + default: + WidgetDetailView(id: widgetType) + } case let .widgetEdit(widgetType): WidgetEditView(id: widgetType) // Settings 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 82914c7b1..842a2462b 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1382,7 +1382,6 @@ "widgets__widget__edit" = "Widget Feed"; "widgets__widget__edit_default" = "Default"; "widgets__widget__edit_custom" = "Custom"; -"widgets__widget__edit_description" = "Please select which fields you want to display in the {name} widget."; "widgets__widget__source" = "Source"; "widgets__add" = "Add Widget"; "widgets__list__button" = "Enable In Settings"; @@ -1391,9 +1390,20 @@ "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__error" = "Couldn\'t get blocks data"; diff --git a/Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift new file mode 100644 index 000000000..043d161dd --- /dev/null +++ b/Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift @@ -0,0 +1,36 @@ +import Foundation +import WidgetKit + +/// Mirrors in-app news widget options into the App Group so the WidgetKit extension can read them, +/// and centralizes the WidgetKit reload trigger for the news home-screen widget. +enum NewsHomeScreenWidgetOptionsStore { + /// WidgetKit `kind` for the home-screen news widget (must match `BitkitNewsWidget`). + static let newsHomeScreenWidgetKind = "BitkitNewsWidget" + + private static let suiteName = "group.bitkit" + private static let key = "home_screen_news_widget_options_v1" + + static func save(_ options: NewsWidgetOptions) { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = try? JSONEncoder().encode(options) + else { return } + defaults.set(data, forKey: key) + } + + static func load() -> NewsWidgetOptions { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = defaults.data(forKey: key), + let options = try? JSONDecoder().decode(NewsWidgetOptions.self, from: data) + else { + return NewsWidgetOptions() + } + return options + } + + /// Call after updating options or cache so the home-screen widget timeline refreshes. + /// No-op when running inside the widget extension itself (`appex`). + static func reloadHomeScreenWidgetIfNeeded() { + guard Bundle.main.bundleURL.pathExtension != "appex" else { return } + WidgetCenter.shared.reloadTimelines(ofKind: newsHomeScreenWidgetKind) + } +} diff --git a/Bitkit/Services/Widgets/NewsService.swift b/Bitkit/Services/Widgets/NewsService.swift index e02681ab3..01ee7a650 100644 --- a/Bitkit/Services/Widgets/NewsService.swift +++ b/Bitkit/Services/Widgets/NewsService.swift @@ -3,24 +3,22 @@ import Foundation /// Service for fetching and caching news articles class NewsService { static let shared = NewsService() - private let cache = UserDefaults.standard - private let cacheKey = "news_widget_cache" - private let baseUrl = "https://feeds.synonym.to/news-feed/api" private let refreshInterval: TimeInterval = 2 * 60 // 2 minutes - private init() {} + private init() { + NewsWidgetCache.legacyDropStandardSuiteCache() + } /// Fetches articles from the news API /// - Returns: Array of articles /// - Throws: URLError or decoding error func fetchArticles() async throws -> [Article] { - guard let url = URL(string: "\(baseUrl)/articles") else { + guard let url = URL(string: WidgetEnv.newsFeedArticlesUrl) else { throw URLError(.badURL) } let (data, response) = try await URLSession.shared.data(from: url) - // Validate HTTP response guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } @@ -29,39 +27,18 @@ class NewsService { throw URLError(.badServerResponse) } - do { - let decoder = JSONDecoder() - return try decoder.decode([Article].self, from: data) - } catch { - throw error - } + return try JSONDecoder().decode([Article].self, from: data) } - /// Caches widget data to UserDefaults - /// - Parameter data: Widget data to cache - func cacheData(_ data: WidgetData) { - do { - let encoder = JSONEncoder() - let encoded = try encoder.encode(data) - cache.set(encoded, forKey: cacheKey) - } catch { - // Handle silently - } - } - - /// Retrieves cached widget data - /// - Returns: Widget data if available + /// Retrieves a cached widget data view by selecting a random article from the App Group cache. func getCachedData() -> WidgetData? { - guard let data = cache.data(forKey: cacheKey) else { - return nil - } - - do { - let decoder = JSONDecoder() - return try decoder.decode(WidgetData.self, from: data) - } catch { - return nil - } + guard let article = NewsWidgetCache.loadTop().randomElement() else { return nil } + return WidgetData( + title: article.title, + timeAgo: timeAgo(from: article.publishedDate), + link: article.link, + publisher: article.publisher + ) } /// Converts a date string to a human-readable time ago format @@ -69,7 +46,7 @@ class NewsService { /// - Returns: Human-readable time difference (e.g. "5 hours ago") func timeAgo(from dateString: String) -> String { let formatter = DateFormatter() - formatter.locale = Locale.current + formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" guard let date = formatter.date(from: dateString) else { @@ -83,60 +60,60 @@ class NewsService { return relativeFormatter.localizedString(for: date, relativeTo: Date()) } + /// Fetches the top 10 most recent articles, persists them to the App Group cache, + /// and triggers a home-screen widget reload. + @discardableResult + func fetchTopArticles() async throws -> [CachedNewsArticle] { + let articles = try await fetchArticles() + let top = articles + .sorted { $0.published > $1.published } + .prefix(10) + .map { article in + CachedNewsArticle( + title: article.title, + publisher: article.publisher.title, + link: article.comments ?? article.link, + publishedDate: article.publishedDate, + publishedEpoch: article.published + ) + } + + NewsWidgetCache.saveTop(top) + NewsHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() + + return top + } + /// Fetches widget data using stale-while-revalidate strategy /// - Parameter returnCachedImmediately: If true, returns cached data immediately if available /// - Returns: Widget data /// - Throws: URLError or decoding error @discardableResult func fetchWidgetData(returnCachedImmediately: Bool = true) async throws -> WidgetData { - // If we want cached data and it exists, return it immediately if returnCachedImmediately, let cachedData = getCachedData() { - // Start fresh fetch in background to update cache (don't await) + // Refresh in background; cache is updated automatically. Task { do { - try await fetchFreshData() - // Cache will be updated automatically in fetchFreshData + try await fetchTopArticles() } catch { - // Silent failure for background updates print("Background news data update failed: \(error)") } } return cachedData } - // No cache available or cache not requested - fetch fresh data - return try await fetchFreshData() - } - - /// Fetches fresh data from API (always hits the network) - @discardableResult - private func fetchFreshData() async throws -> WidgetData { - let articles = try await fetchArticles() - - // Get a random article from the last 10 - let recentArticles = - articles - .sorted { $0.published > $1.published } - .prefix(10) - - guard let article = recentArticles.randomElement() else { + let top = try await fetchTopArticles() + guard let article = top.randomElement() else { Logger.error("No articles available after filtering") throw URLError(.cannotParseResponse) } - let timeAgoString = timeAgo(from: article.publishedDate) - - let widgetData = WidgetData( + return WidgetData( title: article.title, - timeAgo: timeAgoString, - link: article.comments ?? article.link, - publisher: article.publisher.title + timeAgo: timeAgo(from: article.publishedDate), + link: article.link, + publisher: article.publisher ) - - // Cache the data - cacheData(widgetData) - - return widgetData } } @@ -201,7 +178,7 @@ struct Publisher: Codable { let image: String? } -/// Widget data model for caching +/// Widget data model used by the in-app news widget UI. struct WidgetData: Codable { let title: String let timeAgo: String diff --git a/Bitkit/Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift new file mode 100644 index 000000000..b812a5bc6 --- /dev/null +++ b/Bitkit/Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift @@ -0,0 +1,36 @@ +import Foundation +import WidgetKit + +/// Mirrors in-app price widget options into the App Group so the WidgetKit extension can read them, +/// and centralizes the WidgetKit reload trigger for the price home-screen widget. +enum PriceHomeScreenWidgetOptionsStore { + /// WidgetKit `kind` for the home-screen price widget (must match `BitkitPriceWidget`). + static let priceHomeScreenWidgetKind = "BitkitPriceWidget" + + private static let suiteName = "group.bitkit" + private static let key = "home_screen_price_widget_options_v1" + + static func save(_ options: PriceWidgetOptions) { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = try? JSONEncoder().encode(options) + else { return } + defaults.set(data, forKey: key) + } + + static func load() -> PriceWidgetOptions { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = defaults.data(forKey: key), + let options = try? JSONDecoder().decode(PriceWidgetOptions.self, from: data) + else { + return PriceWidgetOptions() + } + return options + } + + /// Call after updating options or cache so the home-screen widget timeline refreshes. + /// No-op when running inside the widget extension itself (`appex`). + static func reloadHomeScreenWidgetIfNeeded() { + guard Bundle.main.bundleURL.pathExtension != "appex" else { return } + WidgetCenter.shared.reloadTimelines(ofKind: priceHomeScreenWidgetKind) + } +} diff --git a/Bitkit/Services/Widgets/PriceService.swift b/Bitkit/Services/Widgets/PriceService.swift index 0569c0e77..411d0f22e 100644 --- a/Bitkit/Services/Widgets/PriceService.swift +++ b/Bitkit/Services/Widgets/PriceService.swift @@ -2,13 +2,6 @@ import Foundation // MARK: - Data Models -public struct TradingPair { - public let name: String - public let base: String - public let quote: String - public let symbol: String -} - struct PriceResponse: Codable { let price: Double let timestamp: Double @@ -38,25 +31,6 @@ struct CandleResponse: Codable { let volume: Double } -struct PriceChange { - let isPositive: Bool - let formatted: String -} - -struct PriceData { - let name: String - let change: PriceChange - let price: String - let pastValues: [Double] -} - -enum GraphPeriod: String, CaseIterable, Codable { - case oneDay = "1D" - case oneWeek = "1W" - case oneMonth = "1M" - case oneYear = "1Y" -} - enum PriceServiceError: Error { case invalidURL case invalidPair @@ -65,66 +39,11 @@ enum PriceServiceError: Error { case noPriceDataAvailable } -// MARK: - Trading Pairs Constants - -public let tradingPairs: [TradingPair] = [ - TradingPair(name: "BTC/USD", base: "BTC", quote: "USD", symbol: "$"), - TradingPair(name: "BTC/EUR", base: "BTC", quote: "EUR", symbol: "€"), - TradingPair(name: "BTC/GBP", base: "BTC", quote: "GBP", symbol: "£"), - TradingPair(name: "BTC/JPY", base: "BTC", quote: "JPY", symbol: "¥"), -] - -/// Convenience array for just the pair names -public let tradingPairNames: [String] = tradingPairs.map(\.name) - -// MARK: - Helper Models - -private struct CachedPriceData: Codable { - let name: String - let changeIsPositive: Bool - let changeFormatted: String - let price: String - let pastValues: [Double] -} - -// MARK: - Caching System - -class PriceWidgetCache { - static let shared = PriceWidgetCache() - private let userDefaults = UserDefaults.standard - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() - - private init() {} - - func set(_ value: some Codable, forKey key: String) { - do { - let data = try encoder.encode(value) - userDefaults.set(data, forKey: "price_widget_cache_\(key)") - } catch { - print("Failed to cache price data for key \(key): \(error)") - } - } - - func get(_ type: T.Type, forKey key: String) -> T? { - guard let data = userDefaults.data(forKey: "price_widget_cache_\(key)") else { - return nil - } - - do { - return try decoder.decode(type, from: data) - } catch { - print("Failed to decode cached price data for key \(key): \(error)") - return nil - } - } -} - // MARK: - Price Service class PriceService { static let shared = PriceService() - private let baseURL = "https://feeds.synonym.to/price-feed/api" + private let baseURL = WidgetEnv.priceFeedBaseUrl private init() {} @@ -190,21 +109,7 @@ class PriceService { } private func getCachedData(pairs: [String], period: GraphPeriod) -> [PriceData]? { - let cache = PriceWidgetCache.shared - let cachedItems = pairs.compactMap { pairName in - cache.get(CachedPriceData.self, forKey: "\(pairName)_\(period.rawValue)") - } - - guard cachedItems.count == pairs.count else { return nil } - - return cachedItems.map { cached in - PriceData( - name: cached.name, - change: PriceChange(isPositive: cached.changeIsPositive, formatted: cached.changeFormatted), - price: cached.price, - pastValues: cached.pastValues - ) - } + PriceWidgetCache.loadAll(pairs: pairs, period: period) } private func fetchPairData(pairName: String, period: GraphPeriod) async throws -> PriceData { @@ -288,15 +193,8 @@ class PriceService { return "\(pair.symbol) \(formatted)" } - private func cacheData(pairName: String, period: GraphPeriod, data: PriceData) { - let cacheKey = "\(pairName)_\(period.rawValue)" - let cachedData = CachedPriceData( - name: data.name, - changeIsPositive: data.change.isPositive, - changeFormatted: data.change.formatted, - price: data.price, - pastValues: data.pastValues - ) - PriceWidgetCache.shared.set(cachedData, forKey: cacheKey) + private func cacheData(pairName _: String, period: GraphPeriod, data: PriceData) { + PriceWidgetCache.save(data, period: period) + PriceHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } } diff --git a/Bitkit/Styles/TextStyle.swift b/Bitkit/Styles/TextStyle.swift index 2624981a2..403564a8f 100644 --- a/Bitkit/Styles/TextStyle.swift +++ b/Bitkit/Styles/TextStyle.swift @@ -619,10 +619,10 @@ private struct FlexibleTextView: View { #Preview { ScrollView { HStack { - DisplayText(t("onboarding__empty_wallet")) + DisplayText("Display Text With An\nAccent Over Here") .background(Color.red.opacity(0.1)) - DisplayText(t("onboarding__welcome_title")) + DisplayText("Display Text With An\nAccent Over Here") .background(Color.blue.opacity(0.1)) } .padding(.bottom, 20) @@ -636,7 +636,7 @@ private struct FlexibleTextView: View { } .padding(.bottom, 20) - DisplayText(t("onboarding__slide0_header")) + DisplayText("Display Text With An\nAccent Over Here") .background(Color.orange.opacity(0.1)) .padding(.bottom, 20) diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 889a76095..6d0e392c8 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -59,14 +59,11 @@ enum WidgetsBackupConverter { } case .price: if let options = try? JSONDecoder().decode(PriceWidgetOptions.self, from: optionsData) { - let androidPairs = options.selectedPairs.map { pair in - pair.replacingOccurrences(of: "/", with: "_") - } + let androidPair = options.selectedPair.replacingOccurrences(of: "/", with: "_") let androidPeriod = convertIosPeriodToAndroid(options.selectedPeriod) pricePreferences = [ - "enabledPairs": androidPairs.isEmpty ? ["BTC_USD"] : androidPairs, + "enabledPairs": [androidPair.isEmpty ? "BTC_USD" : androidPair], "period": androidPeriod, - "showSource": options.showSource, ] } case .calculator, .suggestions: @@ -166,21 +163,20 @@ enum WidgetsBackupConverter { } case .price: if let prefs = jsonDict["pricePreferences"] as? [String: Any] { - var selectedPairs = ["BTC/USD"] - if let pairsArray = prefs["enabledPairs"] as? [String] { - selectedPairs = pairsArray.map { pairType in - pairType.replacingOccurrences(of: "_", with: "/") - } - if selectedPairs.isEmpty { - selectedPairs = ["BTC/USD"] + var selectedPair = "BTC/USD" + if let pairsArray = prefs["enabledPairs"] as? [String], + let firstAndroidPair = pairsArray.first + { + let converted = firstAndroidPair.replacingOccurrences(of: "_", with: "/") + if !converted.isEmpty { + selectedPair = converted } } let period = convertAndroidPeriodToIos(prefs["period"] as? String) let iosOptions = PriceWidgetOptions( - selectedPairs: selectedPairs, - selectedPeriod: period, - showSource: prefs["showSource"] as? Bool ?? false + selectedPair: selectedPair, + selectedPeriod: period ) optionsData = try? JSONEncoder().encode(iosOptions) } @@ -236,14 +232,11 @@ enum WidgetsBackupConverter { private static func getDefaultPricePreferences() -> [String: Any] { let defaults = PriceWidgetOptions() - let androidPairs = defaults.selectedPairs.map { pair in - pair.replacingOccurrences(of: "/", with: "_") - } + let androidPair = defaults.selectedPair.replacingOccurrences(of: "/", with: "_") let androidPeriod = convertIosPeriodToAndroid(defaults.selectedPeriod) return [ - "enabledPairs": androidPairs.isEmpty ? ["BTC_USD"] : androidPairs, + "enabledPairs": [androidPair.isEmpty ? "BTC_USD" : androidPair], "period": androidPeriod, - "showSource": defaults.showSource, ] } diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index cd954ff90..785346ae2 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -188,9 +188,10 @@ class WidgetsViewModel: ObservableObject { // Don't add duplicates guard !isWidgetSaved(type) else { return } - let newSavedWidget = SavedWidget(type: type) - savedWidgetsWithOptions.append(newSavedWidget) - savedWidgets.append(newSavedWidget.toWidget()) + if !savedWidgetsWithOptions.contains(where: { $0.type == type }) { + savedWidgetsWithOptions.append(SavedWidget(type: type)) + } + savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() } @@ -254,6 +255,14 @@ class WidgetsViewModel: ObservableObject { } persistSavedWidgets() + + if type == .price, let priceOptions = options as? PriceWidgetOptions { + syncPriceOptionsToHomeScreenWidget(priceOptions) + } + + if type == .news, let newsOptions = options as? NewsWidgetOptions { + syncNewsOptionsToHomeScreenWidget(newsOptions) + } } catch { print("Failed to save widget options: \(error)") } @@ -311,4 +320,20 @@ 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() + } } 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/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..1b135d91a 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -44,8 +44,8 @@ class WidgetEditLogic: ObservableObject { // Weather widget has multiple options, check if any are enabled return weatherOptions.showStatus || weatherOptions.showText || weatherOptions.showMedian || weatherOptions.showNextBlockFee case .price: - // Price widget has options, check if at least one trading pair is selected - return !priceOptions.selectedPairs.isEmpty + // Price widget always has a selected pair (single-select). + return true case .calculator, .suggestions: return false } @@ -77,7 +77,7 @@ class WidgetEditLogic: ObservableObject { func toggleOption(_ item: WidgetEditItem) { // Don't toggle static items - guard item.type == .toggleItem else { return } + guard item.type != .staticItem else { return } switch widgetType { case .blocks: @@ -138,14 +138,8 @@ class WidgetEditLogic: ObservableObject { } case .price: switch item.key { - case "BTC/USD": - toggleTradingPair("BTC/USD") - case "BTC/EUR": - toggleTradingPair("BTC/EUR") - case "BTC/GBP": - toggleTradingPair("BTC/GBP") - case "BTC/JPY": - toggleTradingPair("BTC/JPY") + case "BTC/USD", "BTC/EUR", "BTC/GBP", "BTC/JPY": + priceOptions.selectedPair = item.key case "1D": priceOptions.selectedPeriod = .oneDay case "1W": @@ -154,8 +148,6 @@ class WidgetEditLogic: ObservableObject { priceOptions.selectedPeriod = .oneMonth case "1Y": priceOptions.selectedPeriod = .oneYear - case "showSource": - priceOptions.showSource.toggle() default: break } @@ -165,14 +157,6 @@ class WidgetEditLogic: ObservableObject { onStateChange?() } - private func toggleTradingPair(_ pairName: String) { - if priceOptions.selectedPairs.contains(pairName) { - priceOptions.selectedPairs.removeAll { $0 == pairName } - } else { - priceOptions.selectedPairs.append(pairName) - } - } - func loadCurrentOptions() { switch widgetType { case .blocks: diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 575f500b6..74852fd92 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 { @@ -290,17 +308,9 @@ enum WidgetEditItemFactory { ) -> [WidgetEditItem] { var items: [WidgetEditItem] = [] - if let data = newsViewModel.widgetData { - items.append( - WidgetEditItem( - key: "showDate", - type: .toggleItem, - titleView: AnyView(BodyMText(data.timeAgo, textColor: .textPrimary)), - valueView: nil, - isChecked: newsOptions.showDate - ) - ) + items.append(sectionHeaderItem(key: "news_content_header", title: t("widgets__news__content_header"))) + if let data = newsViewModel.widgetData { items.append( WidgetEditItem( key: "showTitle", @@ -315,30 +325,30 @@ enum WidgetEditItemFactory { WidgetEditItem( key: "showSource", type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText(data.publisher, textColor: .textSecondary)), + titleView: AnyView(BodySSBText(data.publisher, textColor: .brandAccent)), + valueView: nil, isChecked: newsOptions.showSource ) ) - } else { - // Fallback when no data is available + items.append( WidgetEditItem( key: "showDate", type: .toggleItem, - titleView: AnyView(BodyMText("13 hours ago", textColor: .textPrimary)), + titleView: AnyView(BodySSBText(data.timeAgo, textColor: .textSecondary)), valueView: nil, isChecked: newsOptions.showDate ) ) - + } else { + // Fallback when no data is available items.append( WidgetEditItem( key: "showTitle", - type: .staticItem, - titleView: AnyView(TitleText("Exodus Launches XO Pay, An In-App Bitcoin And Crypto Purchase Solution")), + type: .toggleItem, + titleView: AnyView(TitleText("How Bitcoin changed El Salvador in more ways...")), valueView: nil, - isChecked: true // Static items are always shown + isChecked: newsOptions.showTitle ) ) @@ -346,79 +356,83 @@ enum WidgetEditItemFactory { WidgetEditItem( key: "showSource", type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("Bitcoin Magazine", textColor: .textSecondary)), + titleView: AnyView(BodySSBText("bitcoinmagazine.com", textColor: .brandAccent)), + valueView: nil, isChecked: newsOptions.showSource ) ) + + items.append( + WidgetEditItem( + key: "showDate", + type: .toggleItem, + titleView: AnyView(BodySSBText("1 min ago", textColor: .textSecondary)), + valueView: nil, + isChecked: newsOptions.showDate + ) + ) } return items } @MainActor - static func getPriceItems(priceOptions: PriceWidgetOptions, priceDataByPeriod: [GraphPeriod: [PriceData]] = [:]) -> [WidgetEditItem] { + static func getPriceItems(priceOptions: PriceWidgetOptions, priceDataByPeriod _: [GraphPeriod: [PriceData]] = [:]) -> [WidgetEditItem] { var items: [WidgetEditItem] = [] - // Trading pair options with live or fallback prices - let fallbackPrices = ["$ 43,250", "€ 39,850", "£ 34,120", "¥ 6,245,000"] - - // Use current period data for trading pair prices - let currentPeriodData = priceDataByPeriod[priceOptions.selectedPeriod] ?? [] - - for (index, pair) in tradingPairNames.enumerated() { - // Try to find live data for this pair - let livePrice = currentPeriodData.first { $0.name == pair }?.price ?? fallbackPrices[index] + // CURRENCY section (single-select) + items.append(sectionHeaderItem(key: "currency_header", title: t("widgets__price__currency"))) + let selectedPair = priceOptions.selectedPair + for pair in tradingPairNames { + let isSelected = selectedPair == pair items.append( WidgetEditItem( key: pair, - type: .toggleItem, - title: pair, - value: livePrice, - isChecked: priceOptions.selectedPairs.contains(pair) + type: .radioItem, + titleView: AnyView( + BodySSBText(pair, textColor: isSelected ? .textPrimary : .textSecondary) + ), + valueView: nil, + isChecked: isSelected ) ) } - // Period selection (radio group) with charts - let periods: [GraphPeriod] = [.oneDay, .oneWeek, .oneMonth, .oneYear] - - for period in periods { - // Get data for this specific period - let periodData = priceDataByPeriod[period] ?? [] - let firstPairData = periodData.first + items.append(sectionHeaderItem(key: "timeframe_header", title: t("widgets__price__timeframe"), topInset: 16)) + for period in GraphPeriod.allCases { + let isSelected = priceOptions.selectedPeriod == period items.append( WidgetEditItem( key: period.rawValue, - type: .toggleItem, + type: .radioItem, titleView: AnyView( - PriceChart( - values: firstPairData?.pastValues ?? [], - isPositive: firstPairData?.change.isPositive ?? true, - period: period.rawValue - ) + BodySSBText(period.editScreenLabel, textColor: isSelected ? .textPrimary : .textSecondary) ), valueView: nil, - isChecked: priceOptions.selectedPeriod == period + isChecked: isSelected ) ) } - items.append( - WidgetEditItem( - key: "showSource", - type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("Bitfinex.com", textColor: .textSecondary)), - isChecked: priceOptions.showSource - ) - ) - return items } + private static func sectionHeaderItem(key: String, title: String, topInset: CGFloat = 0) -> WidgetEditItem { + WidgetEditItem( + key: key, + type: .sectionHeader, + titleView: AnyView( + CaptionMText(title, textColor: .textSecondary) + .textCase(.uppercase) + .padding(.top, topInset) + ), + valueView: nil, + isChecked: false + ) + } + @MainActor static func getWeatherItems( weatherViewModel: WeatherViewModel, diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index 8cfc81174..67a5094c2 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -57,12 +57,9 @@ struct WidgetEditView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("widgets__widget__edit")) - .padding(.bottom, 16) - - BodyMText( - t("widgets__widget__edit_description", variables: ["name": widget.name]), - textColor: .textSecondary + NavigationBar( + title: id == .price ? widget.name : t("widgets__widget__edit"), + showMenuButton: id != .price ) .padding(.bottom, 16) @@ -73,7 +70,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 +104,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/btc.imageset/Contents.json b/BitkitWidget/Assets.xcassets/btc.imageset/Contents.json new file mode 100644 index 000000000..50f875c92 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/btc.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "btc.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitkitWidget/Assets.xcassets/btc.imageset/btc.pdf b/BitkitWidget/Assets.xcassets/btc.imageset/btc.pdf new file mode 100644 index 000000000..c40a91328 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/btc.imageset/btc.pdf differ diff --git a/BitkitWidget/BitkitWidget.entitlements b/BitkitWidget/BitkitWidget.entitlements new file mode 100644 index 000000000..f5cc9b7c2 --- /dev/null +++ b/BitkitWidget/BitkitWidget.entitlements @@ -0,0 +1,11 @@ + + + + + com.apple.security.application-groups + + group.bitkit + + + + diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift new file mode 100644 index 000000000..ba9bbdd06 --- /dev/null +++ b/BitkitWidget/BitkitWidget.swift @@ -0,0 +1,10 @@ +import SwiftUI +import WidgetKit + +@main +struct BitkitWidgetBundle: WidgetBundle { + var body: some Widget { + BitkitPriceWidget() + BitkitNewsWidget() + } +} 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.