diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 703b0c05a..e7c80f00c 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 4A319B512E8F24F2002B9AC9 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4A319B502E8F24F2002B9AC9 /* WidgetKit.framework */; }; 4A319B532E8F24F2002B9AC9 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4A319B522E8F24F2002B9AC9 /* SwiftUI.framework */; }; 4A319B622E8F24F4002B9AC9 /* BitkitWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4A319B4F2E8F24F2002B9AC9 /* BitkitWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 4A319B702E8F2600002B9AC9 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4A319B712E8F2600002B9AC9 /* Localizable.strings */; }; 4AAB08CA2E1FE77600BA63DF /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4AAB08C92E1FE77600BA63DF /* Lottie */; }; 961058E32C355B5500E1F1D8 /* BitkitNotification.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 9613018B2C5022D700878183 /* LDKNode */; }; @@ -79,6 +80,21 @@ 4A319B502E8F24F2002B9AC9 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 4A319B522E8F24F2002B9AC9 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 4A319B6E2E8F25F6002B9AC9 /* BitkitWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BitkitWidgetExtension.entitlements; sourceTree = ""; }; + 4A319B722E8F2600002B9AC9 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = Bitkit/Resources/Localization/ar.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B732E8F2600002B9AC9 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = Bitkit/Resources/Localization/ca.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B742E8F2600002B9AC9 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = Bitkit/Resources/Localization/cs.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B752E8F2600002B9AC9 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = Bitkit/Resources/Localization/de.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B762E8F2600002B9AC9 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = Bitkit/Resources/Localization/el.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B772E8F2600002B9AC9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Bitkit/Resources/Localization/en.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B782E8F2600002B9AC9 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "Bitkit/Resources/Localization/es-419.lproj/Localizable.strings"; sourceTree = SOURCE_ROOT; }; + 4A319B792E8F2600002B9AC9 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = Bitkit/Resources/Localization/es.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B7A2E8F2600002B9AC9 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Bitkit/Resources/Localization/fr.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B7B2E8F2600002B9AC9 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = Bitkit/Resources/Localization/it.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B7C2E8F2600002B9AC9 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = Bitkit/Resources/Localization/nl.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B7D2E8F2600002B9AC9 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = Bitkit/Resources/Localization/pl.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B7E2E8F2600002B9AC9 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings"; sourceTree = SOURCE_ROOT; }; + 4A319B7F2E8F2600002B9AC9 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = Bitkit/Resources/Localization/pt.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + 4A319B802E8F2600002B9AC9 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = Bitkit/Resources/Localization/ru.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BitkitNotification.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 96FE1F612C2DE6AA006D0C8B /* Bitkit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bitkit.app; sourceTree = BUILT_PRODUCTS_DIR; }; 96FE1F722C2DE6AC006D0C8B /* BitkitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BitkitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -177,6 +193,7 @@ Models/BlocksWidgetData.swift, Models/BlocksWidgetFields.swift, Models/BlocksWidgetOptions.swift, + Models/BitcoinFacts.swift, Models/NewsWidgetData.swift, Models/NewsWidgetOptions.swift, Models/PriceWidgetData.swift, @@ -274,6 +291,7 @@ 96A44F562CEF5F5400FBACFF /* BitkitUITests */, 96A44F5C2CEF5F5800FBACFF /* BitkitNotification */, 4A319B542E8F24F2002B9AC9 /* BitkitWidget */, + 4A319B712E8F2600002B9AC9 /* Localizable.strings */, 96FE1F622C2DE6AA006D0C8B /* Products */, 961058EC2C35798C00E1F1D8 /* Frameworks */, ); @@ -496,6 +514,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4A319B702E8F2600002B9AC9 /* Localizable.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -529,6 +548,31 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXVariantGroup section */ + 4A319B712E8F2600002B9AC9 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 4A319B722E8F2600002B9AC9 /* ar */, + 4A319B732E8F2600002B9AC9 /* ca */, + 4A319B742E8F2600002B9AC9 /* cs */, + 4A319B752E8F2600002B9AC9 /* de */, + 4A319B762E8F2600002B9AC9 /* el */, + 4A319B772E8F2600002B9AC9 /* en */, + 4A319B782E8F2600002B9AC9 /* es-419 */, + 4A319B792E8F2600002B9AC9 /* es */, + 4A319B7A2E8F2600002B9AC9 /* fr */, + 4A319B7B2E8F2600002B9AC9 /* it */, + 4A319B7C2E8F2600002B9AC9 /* nl */, + 4A319B7D2E8F2600002B9AC9 /* pl */, + 4A319B7E2E8F2600002B9AC9 /* pt-BR */, + 4A319B7F2E8F2600002B9AC9 /* pt */, + 4A319B802E8F2600002B9AC9 /* ru */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + /* Begin PBXShellScriptBuildPhase section */ 96EMBED0012026012000FRAME /* Remove Static Framework Stubs */ = { isa = PBXShellScriptBuildPhase; @@ -621,7 +665,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 186; + CURRENT_PROJECT_VERSION = 187; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitWidget/Info.plist; @@ -633,7 +677,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.0; + MARKETING_VERSION = 2.2.1; PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit.widget"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -654,7 +698,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 186; + CURRENT_PROJECT_VERSION = 187; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitWidget/Info.plist; @@ -666,7 +710,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.0; + MARKETING_VERSION = 2.2.1; PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit.widget"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/BitkitWidget/Assets.xcassets/btc.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/bitcoin.imageset/Contents.json similarity index 78% rename from BitkitWidget/Assets.xcassets/btc.imageset/Contents.json rename to Bitkit/Assets.xcassets/icons/bitcoin.imageset/Contents.json index 50f875c92..8a6583348 100644 --- a/BitkitWidget/Assets.xcassets/btc.imageset/Contents.json +++ b/Bitkit/Assets.xcassets/icons/bitcoin.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "btc.pdf", + "filename" : "bitcoin.pdf", "idiom" : "universal" } ], diff --git a/Bitkit/Assets.xcassets/icons/bitcoin.imageset/bitcoin.pdf b/Bitkit/Assets.xcassets/icons/bitcoin.imageset/bitcoin.pdf new file mode 100644 index 000000000..caaae04e5 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/bitcoin.imageset/bitcoin.pdf differ diff --git a/Bitkit/Components/Widgets/FactsWidget.swift b/Bitkit/Components/Widgets/FactsWidget.swift index 2944c35f1..b4f43db9f 100644 --- a/Bitkit/Components/Widgets/FactsWidget.swift +++ b/Bitkit/Components/Widgets/FactsWidget.swift @@ -1,45 +1,17 @@ import SwiftUI -/// Options for configuring the FactsWidget -struct FactsWidgetOptions: Codable, Equatable { - var showSource: Bool = true -} - struct FactsWidget: View { - /// Configuration options for the widget - var options: FactsWidgetOptions = .init() - - /// Flag indicating if the widget is in editing mode var isEditing: Bool = false - - /// Callback to signal when editing should end var onEditingEnd: (() -> Void)? - /// View model for handling facts data @StateObject private var viewModel = FactsViewModel.shared - /// Initialize the widget - init( - options: FactsWidgetOptions = FactsWidgetOptions(), - isEditing: Bool = false, - onEditingEnd: (() -> Void)? = nil - ) { - self.options = options - self.isEditing = isEditing - self.onEditingEnd = onEditingEnd - } - - /// Initialize with a custom view model (for previews) init( - viewModel: FactsViewModel, - options: FactsWidgetOptions = FactsWidgetOptions(), isEditing: Bool = false, onEditingEnd: (() -> Void)? = nil ) { - self.options = options self.isEditing = isEditing self.onEditingEnd = onEditingEnd - _viewModel = StateObject(wrappedValue: viewModel) } var body: some View { @@ -48,30 +20,57 @@ struct FactsWidget: View { isEditing: isEditing, onEditingEnd: onEditingEnd ) { - VStack(spacing: 0) { - TitleText(viewModel.fact) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) + FactsWidgetWideContent(fact: viewModel.fact) + } + } +} - if options.showSource { - WidgetContentBuilder.sourceRow(source: "synonym.to") - } - } +struct FactsWidgetWideContent: View { + let fact: String + + var body: some View { + HStack(alignment: .top, spacing: 32) { + TitleText(fact) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .leading) + + BitcoinLogo() } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct FactsWidgetCompactContent: View { + let fact: String + + var body: some View { + BodyMSBText(fact) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .leading) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .overlay(alignment: .bottomTrailing) { + BitcoinLogo() + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.gray6) + .cornerRadius(16) + } +} + +private struct BitcoinLogo: View { + var body: some View { + Image("bitcoin") + .resizable() + .frame(width: 32, height: 32) } } #Preview { VStack(spacing: 16) { FactsWidget() - - FactsWidget( - options: FactsWidgetOptions(showSource: false) - ) - - FactsWidget( - isEditing: true - ) + FactsWidget(isEditing: true) } .padding() .background(Color.black) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 136ee5ff1..023305007 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -445,6 +445,8 @@ struct MainNavView: View { NewsWidgetPreviewView() case .blocks: BlocksWidgetPreviewView() + case .facts: + FactsWidgetPreviewView() default: WidgetDetailView(id: widgetType) } diff --git a/Bitkit/Services/Widgets/FactsService.swift b/Bitkit/Models/BitcoinFacts.swift similarity index 81% rename from Bitkit/Services/Widgets/FactsService.swift rename to Bitkit/Models/BitcoinFacts.swift index 5ccfddca3..48d5fca8c 100644 --- a/Bitkit/Services/Widgets/FactsService.swift +++ b/Bitkit/Models/BitcoinFacts.swift @@ -1,26 +1,7 @@ import Foundation -/// Service for managing Bitcoin facts -class FactsService { - static let shared = FactsService() - - private init() {} - - /// Returns a random Bitcoin fact - /// - Returns: A Bitcoin fact string - func getRandomFact() -> String { - return facts.randomElement()! - } - - /// Returns all available Bitcoin facts - /// - Returns: Array of Bitcoin facts - func getAllFacts() -> [String] { - return facts - } - - // MARK: - Private Properties - - private let facts = [ +enum BitcoinFacts { + static let all = [ "Satoshi Nakamoto mined more than 1M Bitcoin.", "You don't need permission to use Bitcoin.", "You don't need a bank account to use Bitcoin.", @@ -36,7 +17,7 @@ class FactsService { "The largest transaction was 500,000 bitcoin.", "Bitcoin is legal tender in El Salvador.", "Not your keys, not your coins.", - "’Bitcoin’ is the network, ‘bitcoin’ is the currency.", + "'Bitcoin' is the network, 'bitcoin' is the currency.", "Bitcoin was not the first digital currency.", "Bitcoin was first created with 31,000 lines of code.", "Bitcoin does not have a CEO.", @@ -48,10 +29,10 @@ class FactsService { "The identity of Bitcoin's inventor is unknown.", "If you lose your keys, you lose your coins.", "Bitcoins don't grow on trees.", - "There can only be 21 million bitcoins. ", + "There can only be 21 million bitcoins.", "Bitcoins are created when a block is mined.", "One bitcoin is 100,000,000 satoshis.", - "The smallest unit of Bitcoin is a “satoshi.”", + "The smallest unit of Bitcoin is a \"satoshi.\"", "Bitcoins live on the blockchain, not in wallets.", "You can hold keys, but you cannot hold bitcoin.", "Private keys allow you to sign transactions.", @@ -84,7 +65,7 @@ class FactsService { "The genesis block reward is not spendable.", "You can count 1 day of blocks on 2 hands.", "There are enough sats for everyone.", - "More computing power ≠ more bitcoin.", + "More computing power != more bitcoin.", "Bitcoin doesn't need your personal info.", "Satoshi considered calling it Netcoin.", ] diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 43a0a8370..c3a2dd786 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -270,10 +270,6 @@ private struct MigrationNewsWidgetOptions: Codable { var showSource: Bool } -private struct MigrationFactsWidgetOptions: Codable { - var showSource: Bool -} - // MARK: - RN Migration Keys enum RNKeychainKey { @@ -1961,17 +1957,6 @@ extension MigrationsService { } } - let factsPrefs = (widgetsDict["factsPreferences"] as? [String: Any]) - ?? (widgetsDict["facts"] as? [String: Any]) - if let prefs = factsPrefs { - let options = MigrationFactsWidgetOptions( - showSource: getBool(from: prefs, key: "showSource", defaultValue: false) - ) - if let data = try? JSONEncoder().encode(options) { - result["facts"] = data - } - } - return result } } diff --git a/Bitkit/Styles/Colors.swift b/Bitkit/Styles/Colors.swift index e3b0533b0..389087b76 100644 --- a/Bitkit/Styles/Colors.swift +++ b/Bitkit/Styles/Colors.swift @@ -10,6 +10,7 @@ extension Color { static let redAccent = Color(hex: 0xE95164) static let yellowAccent = Color(hex: 0xFFD200) static let pubkyGreen = Color(hex: 0xBEFF00) + static let bitcoin = Color(hex: 0xF7931A) // MARK: - Base diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 7783d31f8..706e6660c 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -7,7 +7,6 @@ enum WidgetsBackupConverter { var widgetsArray: [[String: Any]] = [] var blocksPreferences: [String: Any]? var newsPreferences: [String: Any]? - var factsPreferences: [String: Any]? var weatherPreferences: [String: Any]? var pricePreferences: [String: Any]? @@ -42,12 +41,6 @@ enum WidgetsBackupConverter { "showSource": options.showSource, ] } - case .facts: - if let options = try? JSONDecoder().decode(FactsWidgetOptions.self, from: optionsData) { - factsPreferences = [ - "showSource": options.showSource, - ] - } case .weather: if let options = try? JSONDecoder().decode(WeatherWidgetOptions.self, from: optionsData) { weatherPreferences = [ @@ -66,7 +59,7 @@ enum WidgetsBackupConverter { "period": androidPeriod, ] } - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: break } } @@ -75,7 +68,6 @@ enum WidgetsBackupConverter { return [ "widgets": widgetsArray, "headlinePreferences": newsPreferences ?? getDefaultNewsPreferences(), - "factsPreferences": factsPreferences ?? getDefaultFactsPreferences(), "blocksPreferences": blocksPreferences ?? getDefaultBlocksPreferences(), "weatherPreferences": weatherPreferences ?? getDefaultWeatherPreferences(), "pricePreferences": pricePreferences ?? getDefaultPricePreferences(), @@ -140,13 +132,6 @@ enum WidgetsBackupConverter { ) optionsData = try? JSONEncoder().encode(iosOptions) } - case .facts: - if let prefs = jsonDict["factsPreferences"] as? [String: Any] { - let iosOptions = FactsWidgetOptions( - showSource: prefs["showSource"] as? Bool ?? false - ) - optionsData = try? JSONEncoder().encode(iosOptions) - } case .weather: if let prefs = jsonDict["weatherPreferences"] as? [String: Any] { let iosOptions = WeatherWidgetOptions( @@ -176,7 +161,7 @@ enum WidgetsBackupConverter { ) optionsData = try? JSONEncoder().encode(iosOptions) } - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: break } @@ -209,13 +194,6 @@ enum WidgetsBackupConverter { ] } - private static func getDefaultFactsPreferences() -> [String: Any] { - let defaults = FactsWidgetOptions() - return [ - "showSource": defaults.showSource, - ] - } - private static func getDefaultWeatherPreferences() -> [String: Any] { let defaults = WeatherWidgetOptions() return [ diff --git a/Bitkit/ViewModels/Widgets/FactsViewModel.swift b/Bitkit/ViewModels/Widgets/FactsViewModel.swift index dbf10fb2e..4852746e8 100644 --- a/Bitkit/ViewModels/Widgets/FactsViewModel.swift +++ b/Bitkit/ViewModels/Widgets/FactsViewModel.swift @@ -7,19 +7,18 @@ class FactsViewModel: ObservableObject { @Published var fact: String = "" - private let factsService = FactsService.shared private var refreshTimer: Timer? private let refreshInterval: TimeInterval = 2 * 60 // 2 minutes /// Private initializer for the singleton instance private init() { - fact = factsService.getRandomFact() + fact = randomFact() startRefreshTimer() } /// Public initializer for previews and testing init(preview: Bool = true) { - fact = factsService.getRandomFact() + fact = randomFact() } deinit { @@ -29,8 +28,12 @@ class FactsViewModel: ObservableObject { private func startRefreshTimer() { refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in - self?.fact = self?.factsService.getRandomFact() ?? "" + self?.fact = self?.randomFact() ?? "" } } } + + private func randomFact() -> String { + BitcoinFacts.all.randomElement()! + } } diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 73020328f..31b3929c0 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -12,12 +12,10 @@ protocol WidgetOptionsProtocol: Codable, Equatable { /// Default options for each widget type func getDefaultOptions(for type: WidgetType) -> Any { switch type { - case .suggestions, .calculator: + case .suggestions, .calculator, .facts: return EmptyWidgetOptions() case .blocks: return BlocksWidgetOptions() - case .facts: - return FactsWidgetOptions() case .news: return NewsWidgetOptions() case .weather: @@ -74,11 +72,7 @@ struct Widget: Identifiable { case .calculator: CalculatorWidget(isEditing: isEditing, onEditingEnd: onEditingEnd) case .facts: - FactsWidget( - options: widgetsViewModel.getOptions(for: type, as: FactsWidgetOptions.self), - isEditing: isEditing, - onEditingEnd: onEditingEnd - ) + FactsWidget(isEditing: isEditing, onEditingEnd: onEditingEnd) case .news: NewsWidget( options: widgetsViewModel.getOptions(for: type, as: NewsWidgetOptions.self), @@ -275,16 +269,12 @@ class WidgetsViewModel: ObservableObject { /// Check if widget has custom options (different from default) func hasCustomOptions(for type: WidgetType) -> Bool { switch type { - case .suggestions, .calculator: + case .suggestions, .calculator, .facts: return false case .blocks: let current: BlocksWidgetOptions = getOptions(for: type, as: BlocksWidgetOptions.self) let defaultOptions = BlocksWidgetOptions() return current != defaultOptions - case .facts: - let current: FactsWidgetOptions = getOptions(for: type, as: FactsWidgetOptions.self) - let defaultOptions = FactsWidgetOptions() - return current != defaultOptions case .news: let current: NewsWidgetOptions = getOptions(for: type, as: NewsWidgetOptions.self) let defaultOptions = NewsWidgetOptions() diff --git a/Bitkit/Views/Widgets/FactsWidgetPreviewView.swift b/Bitkit/Views/Widgets/FactsWidgetPreviewView.swift new file mode 100644 index 000000000..109d3c45f --- /dev/null +++ b/Bitkit/Views/Widgets/FactsWidgetPreviewView.swift @@ -0,0 +1,161 @@ +import SwiftUI + +/// Preview screen for the Bitcoin Facts widget. +struct FactsWidgetPreviewView: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + + @StateObject private var viewModel = FactsViewModel.shared + + @State private var carouselPage: Int = 0 + @State private var showDeleteAlert = false + + private let widgetType: WidgetType = .facts + + private var widgetName: String { + t("widgets__facts__name") + } + + private var widgetDescription: String { + t("widgets__facts__description") + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(widgetType) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + NavigationBar(title: widgetName, showMenuButton: false) + + BodyMText(widgetDescription, textColor: .textSecondary) + + VStack(spacing: 16) { + carousel + + sizeLabel + + pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + buttonsRow + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteAlert, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } + Button(t("common__delete_yes"), role: .destructive) { onDelete() } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": widgetName])) + } + ) + } + + private var carousel: some View { + TabView(selection: $carouselPage) { + compactPage.tag(0) + widePage.tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(maxHeight: .infinity) + } + + private var compactPage: some View { + VStack { + Spacer(minLength: 0) + FactsWidgetCompactContent(fact: viewModel.fact) + .frame(width: 163, height: 192) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private var widePage: some View { + VStack { + Spacer(minLength: 0) + FactsWidgetWideContent(fact: viewModel.fact) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + .frame(maxWidth: .infinity) + Spacer(minLength: 0) + } + } + + private var sizeLabel: some View { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__widget__size_small") + : t("widgets__widget__size_wide"), + textColor: .textSecondary + ) + .textCase(.uppercase) + Spacer() + } + } + + private var pageIndicator: some View { + HStack(spacing: 8) { + Spacer() + ForEach(0 ..< 2, id: \.self) { index in + Circle() + .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) + .frame(width: 8, height: 8) + } + Spacer() + } + } + + private var buttonsRow: some View { + HStack(spacing: 16) { + if isWidgetSaved { + CustomButton( + title: t("common__delete"), + variant: .secondary, + size: .large, + shouldExpand: true + ) { + showDeleteAlert = true + } + .accessibilityIdentifier("WidgetDelete") + } + + CustomButton( + title: t("widgets__widget__save_widget"), + variant: .primary, + size: .large, + shouldExpand: true, + action: onSave + ) + .accessibilityIdentifier("WidgetSave") + } + } + + private func onSave() { + widgets.saveWidget(widgetType) + navigation.reset() + } + + private func onDelete() { + widgets.deleteWidget(widgetType) + navigation.reset() + } +} + +#Preview { + NavigationStack { + FactsWidgetPreviewView() + .environmentObject(NavigationViewModel()) + .environmentObject(WidgetsViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Widgets/WidgetDetailView.swift b/Bitkit/Views/Widgets/WidgetDetailView.swift index 7bab64f76..362e5f2f4 100644 --- a/Bitkit/Views/Widgets/WidgetDetailView.swift +++ b/Bitkit/Views/Widgets/WidgetDetailView.swift @@ -30,9 +30,9 @@ struct WidgetDetailView: View { /// Check if widget has customization options private var hasOptions: Bool { switch id { - case .blocks, .facts, .news, .price, .weather: + case .blocks, .news, .price, .weather: return true - case .suggestions, .calculator: + case .suggestions, .calculator, .facts: return false } } diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index b530ff4f0..7e50e2f14 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -5,7 +5,6 @@ import SwiftUI @MainActor class WidgetEditLogic: ObservableObject { @Published var blocksOptions = BlocksWidgetOptions() - @Published var factsOptions = FactsWidgetOptions() @Published var newsOptions = NewsWidgetOptions() @Published var weatherOptions = WeatherWidgetOptions() @Published var priceOptions = PriceWidgetOptions() @@ -24,9 +23,9 @@ class WidgetEditLogic: ObservableObject { var hasOptions: Bool { switch widgetType { - case .facts, .blocks, .news, .price, .weather: + case .blocks, .news, .price, .weather: return true - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: return false } } @@ -42,16 +41,13 @@ class WidgetEditLogic: ObservableObject { || blocksOptions.fees case .news: return newsOptions.showTitle || newsOptions.showSource || newsOptions.showDate - case .facts: - // Facts widget's static title is always shown, so it always has an enabled option - return true case .weather: // Weather widget has multiple options, check if any are enabled return weatherOptions.showStatus || weatherOptions.showText || weatherOptions.showMedian || weatherOptions.showNextBlockFee case .price: // Price widget always has a selected pair (single-select). return true - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: return false } } @@ -61,9 +57,6 @@ class WidgetEditLogic: ObservableObject { case .blocks: let defaultOptions = BlocksWidgetOptions() return blocksOptions != defaultOptions - case .facts: - let defaultOptions = FactsWidgetOptions() - return factsOptions != defaultOptions case .news: let defaultOptions = NewsWidgetOptions() return newsOptions != defaultOptions @@ -73,7 +66,7 @@ class WidgetEditLogic: ObservableObject { case .price: let defaultOptions = PriceWidgetOptions() return priceOptions != defaultOptions - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: return false } } @@ -108,13 +101,6 @@ class WidgetEditLogic: ObservableObject { default: break } - case .facts: - switch item.key { - case "showSource": - factsOptions.showSource.toggle() - default: - break - } case .news: switch item.key { case "showDate": @@ -154,7 +140,7 @@ class WidgetEditLogic: ObservableObject { default: break } - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: break } onStateChange?() @@ -179,15 +165,13 @@ class WidgetEditLogic: ObservableObject { switch widgetType { case .blocks: blocksOptions = widgetsViewModel.getOptions(for: widgetType, as: BlocksWidgetOptions.self) - case .facts: - factsOptions = widgetsViewModel.getOptions(for: widgetType, as: FactsWidgetOptions.self) case .news: newsOptions = widgetsViewModel.getOptions(for: widgetType, as: NewsWidgetOptions.self) case .weather: weatherOptions = widgetsViewModel.getOptions(for: widgetType, as: WeatherWidgetOptions.self) case .price: priceOptions = widgetsViewModel.getOptions(for: widgetType, as: PriceWidgetOptions.self) - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: break } } @@ -196,15 +180,13 @@ class WidgetEditLogic: ObservableObject { switch widgetType { case .blocks: blocksOptions = BlocksWidgetOptions() - case .facts: - factsOptions = FactsWidgetOptions() case .news: newsOptions = NewsWidgetOptions() case .weather: weatherOptions = WeatherWidgetOptions() case .price: priceOptions = PriceWidgetOptions() - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: break } onStateChange?() @@ -214,15 +196,13 @@ class WidgetEditLogic: ObservableObject { switch widgetType { case .blocks: widgetsViewModel.saveOptions(blocksOptions, for: widgetType) - case .facts: - widgetsViewModel.saveOptions(factsOptions, for: widgetType) case .news: widgetsViewModel.saveOptions(newsOptions, for: widgetType) case .weather: widgetsViewModel.saveOptions(weatherOptions, for: widgetType) case .price: widgetsViewModel.saveOptions(priceOptions, for: widgetType) - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: break } } diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 5a347f9c1..cffbb22cd 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -110,32 +110,6 @@ enum WidgetEditItemFactory { return items } - @MainActor - static func getFactsItems(factsViewModel: FactsViewModel, factsOptions: FactsWidgetOptions) -> [WidgetEditItem] { - var items: [WidgetEditItem] = [] - - items.append( - WidgetEditItem( - key: "showTitle", - type: .staticItem, - titleView: AnyView(TitleText(factsViewModel.fact)), - isChecked: true - ) - ) - - items.append( - WidgetEditItem( - key: "showSource", - type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("mempool.space", textColor: .textSecondary)), - isChecked: factsOptions.showSource - ) - ) - - return items - } - @MainActor static func getNewsItems( newsViewModel: NewsViewModel, @@ -365,12 +339,10 @@ enum WidgetEditItemFactory { static func getItems( for widgetType: WidgetType, blocksViewModel: BlocksViewModel, - factsViewModel: FactsViewModel, newsViewModel: NewsViewModel, priceDataByPeriod: [GraphPeriod: [PriceData]] = [:], weatherViewModel: WeatherViewModel, blocksOptions: BlocksWidgetOptions, - factsOptions: FactsWidgetOptions, newsOptions: NewsWidgetOptions, priceOptions: PriceWidgetOptions, weatherOptions: WeatherWidgetOptions @@ -378,15 +350,13 @@ enum WidgetEditItemFactory { switch widgetType { case .blocks: return getBlocksItems(blocksViewModel: blocksViewModel, blocksOptions: blocksOptions) - case .facts: - return getFactsItems(factsViewModel: factsViewModel, factsOptions: factsOptions) case .news: return getNewsItems(newsViewModel: newsViewModel, newsOptions: newsOptions) case .price: return getPriceItems(priceOptions: priceOptions, priceDataByPeriod: priceDataByPeriod) case .weather: return getWeatherItems(weatherViewModel: weatherViewModel, weatherOptions: weatherOptions) - case .calculator, .suggestions: + case .calculator, .suggestions, .facts: return [] } } diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index d7714f5d2..b07374d4a 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -15,7 +15,6 @@ struct WidgetEditView: View { // View models for getting actual content @StateObject private var blocksViewModel = BlocksViewModel.shared - @StateObject private var factsViewModel = FactsViewModel.shared @StateObject private var newsViewModel = NewsViewModel.shared @StateObject private var priceViewModel = PriceViewModel.shared @StateObject private var weatherViewModel = WeatherViewModel.shared @@ -34,12 +33,10 @@ struct WidgetEditView: View { return WidgetEditItemFactory.getItems( for: id, blocksViewModel: blocksViewModel, - factsViewModel: factsViewModel, newsViewModel: newsViewModel, priceDataByPeriod: priceViewModel.dataByPeriod, weatherViewModel: weatherViewModel, blocksOptions: editLogic.blocksOptions, - factsOptions: editLogic.factsOptions, newsOptions: editLogic.newsOptions, priceOptions: editLogic.priceOptions, weatherOptions: editLogic.weatherOptions diff --git a/BitkitWidget/Assets.xcassets/bitcoin.imageset/Contents.json b/BitkitWidget/Assets.xcassets/bitcoin.imageset/Contents.json new file mode 100644 index 000000000..a64f61927 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/bitcoin.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "bitcoin.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/bitcoin.imageset/bitcoin.pdf b/BitkitWidget/Assets.xcassets/bitcoin.imageset/bitcoin.pdf new file mode 100644 index 000000000..8085e5f48 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/bitcoin.imageset/bitcoin.pdf differ diff --git a/BitkitWidget/Assets.xcassets/btc.imageset/btc.pdf b/BitkitWidget/Assets.xcassets/btc.imageset/btc.pdf deleted file mode 100644 index c40a91328..000000000 Binary files a/BitkitWidget/Assets.xcassets/btc.imageset/btc.pdf and /dev/null differ diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift index 48d2778c4..dfd5eb8f9 100644 --- a/BitkitWidget/BitkitWidget.swift +++ b/BitkitWidget/BitkitWidget.swift @@ -7,5 +7,6 @@ struct BitkitWidgetBundle: WidgetBundle { BitkitPriceWidget() BitkitNewsWidget() BitkitBlocksWidget() + BitkitFactsWidget() } } diff --git a/BitkitWidget/FactsHomeScreenWidget.swift b/BitkitWidget/FactsHomeScreenWidget.swift new file mode 100644 index 000000000..c51b1381d --- /dev/null +++ b/BitkitWidget/FactsHomeScreenWidget.swift @@ -0,0 +1,141 @@ +import SwiftUI +import WidgetKit + +// MARK: - Entry + +struct FactsWidgetEntry: TimelineEntry { + let date: Date + let fact: String +} + +// MARK: - Timeline Provider + +struct FactsWidgetProvider: TimelineProvider { + private static let refreshInterval: TimeInterval = 2 * 60 + + func placeholder(in _: Context) -> FactsWidgetEntry { + FactsWidgetEntry(date: Date(), fact: BitcoinFacts.all[0]) + } + + func getSnapshot(in _: Context, completion: @escaping (FactsWidgetEntry) -> Void) { + completion(entry(at: Date())) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + let nextRefresh = now.addingTimeInterval(Self.refreshInterval) + completion(Timeline(entries: [entry(at: now)], policy: .after(nextRefresh))) + } + + private func entry(at date: Date) -> FactsWidgetEntry { + let bucket = Int(date.timeIntervalSince1970 / Self.refreshInterval) + let index = abs(bucket) % BitcoinFacts.all.count + return FactsWidgetEntry(date: date, fact: BitcoinFacts.all[index]) + } +} + +// MARK: - View + +struct FactsHomeScreenWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode + + var entry: FactsWidgetProvider.Entry + + var body: some View { + content + .containerBackground(for: .widget) { backgroundView } + } + + @ViewBuilder + private var content: some View { + switch widgetFamily { + case .systemSmall: + compactLayout + default: + wideLayout + } + } + + private var compactLayout: some View { + Text(entry.fact) + .font(Fonts.semiBold(size: 17)) + .foregroundColor(textColor) + .lineLimit(4) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .overlay(alignment: .bottomTrailing) { + bitcoinLogo + } + .widgetAccentable() + } + + private var wideLayout: some View { + HStack(alignment: .top, spacing: 32) { + Text(entry.fact) + .font(Fonts.bold(size: 22)) + .foregroundColor(textColor) + .lineLimit(4) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity, alignment: .topLeading) + .widgetAccentable() + + bitcoinLogo + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var bitcoinLogo: some View { + Group { + if widgetRenderingMode == .fullColor { + ZStack { + Circle() + .fill(Color.bitcoin) + + bitcoinGlyph + .foregroundColor(.white) + } + } else { + ZStack { + Circle() + .fill(Color.white) + + bitcoinGlyph + .blendMode(.destinationOut) + } + .compositingGroup() + } + } + .frame(width: 32, height: 32) + } + + private var bitcoinGlyph: some View { + Image("bitcoin") + .resizable() + .renderingMode(.template) + } + + private var backgroundView: some View { + widgetRenderingMode == .fullColor ? Color.gray6 : Color.clear + } + + private var textColor: Color { + widgetRenderingMode == .fullColor ? .white : .primary + } +} + +// MARK: - Widget Configuration + +struct BitkitFactsWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration( + kind: "BitkitFactsWidget", + provider: FactsWidgetProvider() + ) { entry in + FactsHomeScreenWidgetEntryView(entry: entry) + } + .configurationDisplayName("widgets__facts__name") + .description("widgets__facts__description") + .supportedFamilies([.systemSmall, .systemMedium]) + } +}