From c2e2bd3d6b827118ce9be5363c97c7577f6c163a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 10:33:41 -0300 Subject: [PATCH 1/3] feat: migrate blocks to v61 and implement OS widget --- Bitkit.xcodeproj/project.pbxproj | 4 + Bitkit/Components/Widgets/BlocksWidget.swift | 184 +++++++------ Bitkit/MainNavView.swift | 2 + Bitkit/Models/BlocksWidgetData.swift | 43 +++ Bitkit/Models/BlocksWidgetFields.swift | 85 ++++++ Bitkit/Models/BlocksWidgetOptions.swift | 55 ++++ .../Localization/en.lproj/Localizable.strings | 4 + .../BlocksHomeScreenWidgetOptionsStore.swift | 36 +++ Bitkit/Services/Widgets/BlocksService.swift | 147 ++++------- Bitkit/Utilities/WidgetsBackupConverter.swift | 6 +- .../ViewModels/Widgets/BlocksViewModel.swift | 2 +- Bitkit/ViewModels/WidgetsViewModel.swift | 13 + .../Widgets/BlocksWidgetPreviewView.swift | 247 ++++++++++++++++++ Bitkit/Views/Widgets/WidgetEditLogic.swift | 20 +- Bitkit/Views/Widgets/WidgetEditModels.swift | 234 +++-------------- Bitkit/Views/Widgets/WidgetEditView.swift | 14 +- .../calendar.imageset/Contents.json | 15 ++ .../calendar.imageset/calendar.pdf | Bin 0 -> 4026 bytes .../clock.imageset/Contents.json | 15 ++ .../Assets.xcassets/clock.imageset/clock.pdf | Bin 0 -> 3922 bytes .../coins.imageset/Contents.json | 15 ++ .../Assets.xcassets/coins.imageset/coins.pdf | Bin 0 -> 11668 bytes .../cube.imageset/Contents.json | 15 ++ .../Assets.xcassets/cube.imageset/cube.pdf | Bin 0 -> 6277 bytes .../file-text.imageset/Contents.json | 15 ++ .../file-text.imageset/file-text.pdf | Bin 0 -> 5903 bytes .../globe.imageset/Contents.json | 15 ++ .../Assets.xcassets/globe.imageset/globe.pdf | Bin 0 -> 4496 bytes .../transfer.imageset/Contents.json | 15 ++ .../transfer.imageset/transfer.pdf | Bin 0 -> 3957 bytes BitkitWidget/BitkitWidget.swift | 1 + BitkitWidget/BlocksHomeScreenWidget.swift | 216 +++++++++++++++ BitkitWidget/BlocksWidgetService.swift | 106 ++++++++ changelog.d/next/blocks-widget-v61.added.md | 1 + 34 files changed, 1114 insertions(+), 411 deletions(-) create mode 100644 Bitkit/Models/BlocksWidgetData.swift create mode 100644 Bitkit/Models/BlocksWidgetFields.swift create mode 100644 Bitkit/Models/BlocksWidgetOptions.swift create mode 100644 Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift create mode 100644 Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift create mode 100644 BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/calendar.imageset/calendar.pdf create mode 100644 BitkitWidget/Assets.xcassets/clock.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/clock.imageset/clock.pdf create mode 100644 BitkitWidget/Assets.xcassets/coins.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/coins.imageset/coins.pdf create mode 100644 BitkitWidget/Assets.xcassets/cube.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/cube.imageset/cube.pdf create mode 100644 BitkitWidget/Assets.xcassets/file-text.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/file-text.imageset/file-text.pdf create mode 100644 BitkitWidget/Assets.xcassets/globe.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/globe.imageset/globe.pdf create mode 100644 BitkitWidget/Assets.xcassets/transfer.imageset/Contents.json create mode 100644 BitkitWidget/Assets.xcassets/transfer.imageset/transfer.pdf create mode 100644 BitkitWidget/BlocksHomeScreenWidget.swift create mode 100644 BitkitWidget/BlocksWidgetService.swift create mode 100644 changelog.d/next/blocks-widget-v61.added.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 15e22deb6..6c3746859 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -173,10 +173,14 @@ Fonts/InterTight-Regular.ttf, Constants/WidgetEnv.swift, Fonts/InterTight-SemiBold.ttf, + Models/BlocksWidgetData.swift, + Models/BlocksWidgetFields.swift, + Models/BlocksWidgetOptions.swift, Models/NewsWidgetData.swift, Models/NewsWidgetOptions.swift, Models/PriceWidgetData.swift, Models/PriceWidgetOptions.swift, + Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift, Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift, Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift, Styles/Colors.swift, diff --git a/Bitkit/Components/Widgets/BlocksWidget.swift b/Bitkit/Components/Widgets/BlocksWidget.swift index 5b6cda4bf..94b29626c 100644 --- a/Bitkit/Components/Widgets/BlocksWidget.swift +++ b/Bitkit/Components/Widgets/BlocksWidget.swift @@ -1,34 +1,28 @@ import SwiftUI -/// Options for configuring the BlocksWidget -struct BlocksWidgetOptions: Codable, Equatable { - var height: Bool = true - var time: Bool = true - var date: Bool = true - var transactionCount: Bool = false - var size: Bool = false - var weight: Bool = false - var difficulty: Bool = false - var hash: Bool = false - var merkleRoot: Bool = false - var showSource: Bool = false +// MARK: - In-app label override + +/// In-app screens use the localized `widgets__widget__source` value for the Source field; +/// the OS widget uses the hardcoded English `BlocksWidgetField.label` since the widget +/// extension target does not have access to `LocalizeHelpers`. +extension BlocksWidgetField { + var inAppLabel: String { + if self == .showSource { return t("widgets__widget__source") } + return label + } } -/// A widget that displays Bitcoin block information +// MARK: - Widget + +/// In-app Bitcoin Blocks widget (v61). Renders the wide layout — used inside the home feed +/// and the wide carousel page on the preview screen. struct BlocksWidget: View { - /// Configuration options for the widget var options: BlocksWidgetOptions = .init() - - /// Flag indicating if the widget is in editing mode var isEditing: Bool = false - - /// Callback to signal when editing should end var onEditingEnd: (() -> Void)? - /// View model for handling block data @StateObject private var viewModel = BlocksViewModel.shared - /// Initialize the widget init( options: BlocksWidgetOptions = BlocksWidgetOptions(), isEditing: Bool = false, @@ -39,96 +33,96 @@ struct BlocksWidget: View { self.onEditingEnd = onEditingEnd } - /// Mapping of block data keys to display labels - private let blocksMapping: [String: String] = [ - "height": "Block", - "time": "Time", - "date": "Date", - "transactionCount": "Transactions", - "size": "Size", - "weight": "Weight", - "difficulty": "Difficulty", - "hash": "Hash", - "merkleRoot": "Merkle Root", - ] - var body: some View { BaseWidget( type: .blocks, isEditing: isEditing, onEditingEnd: onEditingEnd ) { - VStack(spacing: 0) { - if viewModel.isLoading { - WidgetContentBuilder.loadingView() - } else if viewModel.error != nil { - WidgetContentBuilder.errorView(t("widgets__blocks__error")) - } else if let data = viewModel.blockData { - VStack(spacing: 0) { - // Display block data rows based on options - ForEach(getDisplayableData(data), id: \.key) { item in - HStack(spacing: 0) { - HStack { - BodySSBText(item.label, textColor: .textSecondary) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) - - HStack { - BodyMSBText(item.value) - .lineLimit(1) - .truncationMode(.middle) - } - .frame(maxWidth: .infinity, alignment: .trailing) - } - .frame(minHeight: 28) - } - - if options.showSource { - WidgetContentBuilder.sourceRow(source: "mempool.space") - } - } - } - } + content } - .onAppear { + .task { viewModel.startUpdates() } } - /// Get displayable data based on current options - private func getDisplayableData(_ data: BlockData) -> [(key: String, label: String, value: String)] { - var items: [(key: String, label: String, value: String)] = [] - - if options.height { - items.append((key: "height", label: blocksMapping["height"]!, value: data.height)) - } - if options.time { - items.append((key: "time", label: blocksMapping["time"]!, value: data.time)) + @ViewBuilder + private var content: some View { + if viewModel.isLoading && viewModel.blockData == nil { + WidgetContentBuilder.loadingView() + } else if viewModel.error != nil && viewModel.blockData == nil { + WidgetContentBuilder.errorView(t("widgets__blocks__error")) + } else if let data = viewModel.blockData { + BlocksWidgetWideContent(data: data, options: options) } - if options.date { - items.append((key: "date", label: blocksMapping["date"]!, value: data.date)) - } - if options.transactionCount { - items.append((key: "transactionCount", label: blocksMapping["transactionCount"]!, value: data.transactionCount)) - } - if options.size { - items.append((key: "size", label: blocksMapping["size"]!, value: data.size)) - } - if options.weight { - items.append((key: "weight", label: blocksMapping["weight"]!, value: data.weight)) - } - if options.difficulty { - items.append((key: "difficulty", label: blocksMapping["difficulty"]!, value: data.difficulty)) - } - if options.hash { - items.append((key: "hash", label: blocksMapping["hash"]!, value: data.hash)) + } +} + +// MARK: - Wide layout (in-app + 343-wide carousel page + .systemMedium / .systemLarge OS widget) + +struct BlocksWidgetWideContent: View { + let data: CachedBlock + let options: BlocksWidgetOptions + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(options.enabledFields, id: \.self) { field in + BlocksWidgetWideRow(field: field, value: field.value(from: data)) + } } - if options.merkleRoot { - items.append((key: "merkleRoot", label: blocksMapping["merkleRoot"]!, value: data.merkleRoot)) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct BlocksWidgetWideRow: View { + let field: BlocksWidgetField + let value: String + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image(field.iconName) + .resizable() + .renderingMode(.template) + .foregroundColor(.brandAccent) + .frame(width: 20, height: 20) + + BodyMText(field.inAppLabel, textColor: .white80) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + BodyMSBText(value) + .lineLimit(1) + .truncationMode(.middle) } + } +} + +// MARK: - Compact layout (small carousel preview + 163×192 OS small widget) - return items +struct BlocksWidgetCompactContent: View { + let data: CachedBlock + let options: BlocksWidgetOptions + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(options.compactFields, id: \.self) { field in + HStack(alignment: .center, spacing: 8) { + Image(field.iconName) + .resizable() + .renderingMode(.template) + .foregroundColor(.brandAccent) + .frame(width: 20, height: 20) + + BodySSBText(field.value(from: data)) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.gray6) + .cornerRadius(16) } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index ba6c2e3f4..136ee5ff1 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -443,6 +443,8 @@ struct MainNavView: View { PriceWidgetPreviewView() case .news: NewsWidgetPreviewView() + case .blocks: + BlocksWidgetPreviewView() default: WidgetDetailView(id: widgetType) } diff --git a/Bitkit/Models/BlocksWidgetData.swift b/Bitkit/Models/BlocksWidgetData.swift new file mode 100644 index 000000000..f3f9c0d08 --- /dev/null +++ b/Bitkit/Models/BlocksWidgetData.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Persistable representation of the latest mined block, shared between the main app and the +/// widget extension via the App Group. Strings are pre-formatted by the main-app `BlocksService` +/// so the widget extension can render without re-running locale-sensitive formatting. +struct CachedBlock: Codable, Equatable { + let height: String + let time: String + let date: String + let transactionCount: String + let size: String + let fees: String +} + +/// Cache reader/writer used by both the main app and the widget extension. +enum BlocksWidgetCache { + static let appGroupSuiteName = "group.bitkit" + private static let latestKey = "blocks_widget_latest_v1" + private static let legacyStandardKey = "blocks_widget_cache" + + private static func defaults() -> UserDefaults { + UserDefaults(suiteName: appGroupSuiteName) ?? .standard + } + + static func saveLatest(_ block: CachedBlock) { + guard let encoded = try? JSONEncoder().encode(block) else { return } + defaults().set(encoded, forKey: latestKey) + } + + static func loadLatest() -> CachedBlock? { + guard let data = defaults().data(forKey: latestKey), + let decoded = try? JSONDecoder().decode(CachedBlock.self, from: data) + else { + return nil + } + return decoded + } + + /// One-time cleanup of the pre-App-Group cache that lived in `UserDefaults.standard`. + static func legacyDropStandardSuiteCache() { + UserDefaults.standard.removeObject(forKey: legacyStandardKey) + } +} diff --git a/Bitkit/Models/BlocksWidgetFields.swift b/Bitkit/Models/BlocksWidgetFields.swift new file mode 100644 index 000000000..31272ccfd --- /dev/null +++ b/Bitkit/Models/BlocksWidgetFields.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Ordered field set used by the v61 Blocks widget. Default-selected fields come first so +/// the compact (`.systemSmall`) layout can prioritize them when the row cap kicks in. +/// +/// Shared between the main app and the WidgetKit extension via the App Group target membership. +/// Labels are intentionally hardcoded English to avoid reaching into the main app's +/// `LocalizeHelpers` from the widget extension. +enum BlocksWidgetField: String, CaseIterable { + case height + case time + case date + case transactionCount + case size + case fees + case showSource + + /// The four fields enabled by default. The compact layout always renders these first when + /// present, then fills any remaining capacity with non-default fields. + static let defaults: [BlocksWidgetField] = [.height, .time, .date, .transactionCount] + static let extras: [BlocksWidgetField] = [.size, .fees, .showSource] + + var label: String { + switch self { + case .height: return "Block" + case .time: return "Time" + case .date: return "Date" + case .transactionCount: return "Transactions" + case .size: return "Size" + case .fees: return "Fees" + case .showSource: return "Source" + } + } + + /// Asset name for the brand-orange icon used in both the wide and compact layouts. + var iconName: String { + switch self { + case .height: return "cube" + case .time: return "clock" + case .date: return "calendar" + case .transactionCount: return "transfer" + case .size: return "file-text" + case .fees: return "coins" + case .showSource: return "globe" + } + } + + func isEnabled(in options: BlocksWidgetOptions) -> Bool { + switch self { + case .height: return options.height + case .time: return options.time + case .date: return options.date + case .transactionCount: return options.transactionCount + case .size: return options.size + case .fees: return options.fees + case .showSource: return options.showSource + } + } + + func value(from data: CachedBlock) -> String { + switch self { + case .height: return data.height + case .time: return data.time + case .date: return data.date + case .transactionCount: return data.transactionCount + case .size: return data.size + case .fees: return data.fees + case .showSource: return "mempool.space" + } + } +} + +extension BlocksWidgetOptions { + /// All enabled fields in declared order. Used by the wide / large layouts. + var enabledFields: [BlocksWidgetField] { + BlocksWidgetField.allCases.filter { $0.isEnabled(in: self) } + } + + /// Compact layout caps at 4 fields. Defaults come first, extras fill any remaining slots. + var compactFields: [BlocksWidgetField] { + let defaults = BlocksWidgetField.defaults.filter { $0.isEnabled(in: self) } + let extras = BlocksWidgetField.extras.filter { $0.isEnabled(in: self) } + return Array((defaults + extras).prefix(4)) + } +} diff --git a/Bitkit/Models/BlocksWidgetOptions.swift b/Bitkit/Models/BlocksWidgetOptions.swift new file mode 100644 index 000000000..1054be97e --- /dev/null +++ b/Bitkit/Models/BlocksWidgetOptions.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Options for configuring the in-app and home-screen Bitcoin Blocks widgets (shared via App Group). +/// +/// v61 reduces the field set to seven (Block / Time / Date / Transactions / Size / Fees / Source). +/// The custom decoder silently drops legacy keys (`weight`, `difficulty`, `hash`, `merkleRoot`) and +/// fills in defaults for any keys missing from older persisted blobs. +struct BlocksWidgetOptions: Codable, Equatable { + var height: Bool = true + var time: Bool = true + var date: Bool = true + var transactionCount: Bool = false + var size: Bool = false + var fees: Bool = false + var showSource: Bool = false + + init( + height: Bool = true, + time: Bool = true, + date: Bool = true, + transactionCount: Bool = false, + size: Bool = false, + fees: Bool = false, + showSource: Bool = false + ) { + self.height = height + self.time = time + self.date = date + self.transactionCount = transactionCount + self.size = size + self.fees = fees + self.showSource = showSource + } + + private enum CodingKeys: String, CodingKey { + case height + case time + case date + case transactionCount + case size + case fees + case showSource + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + height = try container.decodeIfPresent(Bool.self, forKey: .height) ?? true + time = try container.decodeIfPresent(Bool.self, forKey: .time) ?? true + date = try container.decodeIfPresent(Bool.self, forKey: .date) ?? true + transactionCount = try container.decodeIfPresent(Bool.self, forKey: .transactionCount) ?? false + size = try container.decodeIfPresent(Bool.self, forKey: .size) ?? false + fees = try container.decodeIfPresent(Bool.self, forKey: .fees) ?? false + showSource = try container.decodeIfPresent(Bool.self, forKey: .showSource) ?? false + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index c32f9de47..0c3c528fe 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1411,6 +1411,10 @@ "widgets__blocks__name" = "Bitcoin Blocks"; "widgets__blocks__description" = "Examine various statistics on newly mined Bitcoin Blocks."; "widgets__blocks__error" = "Couldn\'t get blocks data"; +"widgets__blocks__widget_settings" = "Widget Settings"; +"widgets__blocks__size_small" = "Small"; +"widgets__blocks__size_wide" = "Wide"; +"widgets__blocks__data_header" = "Data"; "widgets__facts__name" = "Bitcoin Facts"; "widgets__facts__description" = "Discover fun facts about Bitcoin, every time you open your wallet."; "widgets__calculator__name" = "Bitcoin Calculator"; diff --git a/Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift new file mode 100644 index 000000000..7b23a1f23 --- /dev/null +++ b/Bitkit/Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift @@ -0,0 +1,36 @@ +import Foundation +import WidgetKit + +/// Mirrors in-app Blocks widget options into the App Group so the WidgetKit extension can read them, +/// and centralizes the WidgetKit reload trigger for the Blocks home-screen widget. +enum BlocksHomeScreenWidgetOptionsStore { + /// WidgetKit `kind` for the home-screen Blocks widget (must match `BitkitBlocksWidget`). + static let blocksHomeScreenWidgetKind = "BitkitBlocksWidget" + + private static let suiteName = "group.bitkit" + private static let key = "home_screen_blocks_widget_options_v1" + + static func save(_ options: BlocksWidgetOptions) { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = try? JSONEncoder().encode(options) + else { return } + defaults.set(data, forKey: key) + } + + static func load() -> BlocksWidgetOptions { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = defaults.data(forKey: key), + let options = try? JSONDecoder().decode(BlocksWidgetOptions.self, from: data) + else { + return BlocksWidgetOptions() + } + return options + } + + /// Call after updating options or cache so the home-screen widget timeline refreshes. + /// No-op when running inside the widget extension itself (`appex`). + static func reloadHomeScreenWidgetIfNeeded() { + guard Bundle.main.bundleURL.pathExtension != "appex" else { return } + WidgetCenter.shared.reloadTimelines(ofKind: blocksHomeScreenWidgetKind) + } +} diff --git a/Bitkit/Services/Widgets/BlocksService.swift b/Bitkit/Services/Widgets/BlocksService.swift index 4f00895c3..6e5df6d80 100644 --- a/Bitkit/Services/Widgets/BlocksService.swift +++ b/Bitkit/Services/Widgets/BlocksService.swift @@ -1,129 +1,94 @@ import Foundation -/// Service for fetching and caching Bitcoin block data +/// Service for fetching and caching the latest mined Bitcoin block. +/// +/// Writes the result to the App Group cache (`BlocksWidgetCache`) so the WidgetKit extension +/// can surface the same data, and triggers a timeline reload on the home-screen widget after +/// a successful fresh fetch. class BlocksService { static let shared = BlocksService() - private let cache = UserDefaults.standard - private let cacheKey = "blocks_widget_cache" private let baseUrl = "https://mempool.space/api" private let refreshInterval: TimeInterval = 2 * 60 // 2 minutes - private init() {} + private init() { + BlocksWidgetCache.legacyDropStandardSuiteCache() + } - /// Fetches the latest block data using stale-while-revalidate strategy - /// - Parameter returnCachedImmediately: If true, returns cached data immediately if available - /// - Returns: Block data - /// - Throws: URLError or decoding error + /// Fetches the latest block data using stale-while-revalidate strategy. + /// - Parameter returnCachedImmediately: If true, returns cached data immediately if available. @discardableResult - func fetchBlockData(returnCachedImmediately: Bool = true) async throws -> BlockData { - // If we want cached data and it exists, return it immediately + func fetchBlockData(returnCachedImmediately: Bool = true) async throws -> CachedBlock { if returnCachedImmediately, let cachedData = getCachedData() { - // Start fresh fetch in background to update cache (don't await) + // Background refresh; cache is updated automatically inside fetchFreshData. Task { do { try await fetchFreshData() - // Cache will be updated automatically in fetchFreshData } catch { - // Silent failure for background updates print("Background blocks data update failed: \(error)") } } return cachedData } - // No cache available or cache not requested - fetch fresh data return try await fetchFreshData() } - /// Fetches fresh data from API (always hits the network) + /// Fetches fresh data from the mempool API. @discardableResult - private func fetchFreshData() async throws -> BlockData { - // First get the tip hash + private func fetchFreshData() async throws -> CachedBlock { guard let tipUrl = URL(string: "\(baseUrl)/blocks/tip/hash") else { throw URLError(.badURL) } let (hashData, hashResponse) = try await URLSession.shared.data(from: tipUrl) - // Validate HTTP response - guard let httpResponse = hashResponse as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - guard httpResponse.statusCode == 200 else { + guard let httpResponse = hashResponse as? HTTPURLResponse, + httpResponse.statusCode == 200 + else { throw URLError(.badServerResponse) } let hash = String(data: hashData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - // Now get the block info - guard let blockUrl = URL(string: "\(baseUrl)/block/\(hash)") else { + // The v1 endpoint returns the same fields as the legacy one plus an `extras` block with `totalFees`. + guard let blockUrl = URL(string: "\(baseUrl)/v1/block/\(hash)") else { throw URLError(.badURL) } let (blockData, blockResponse) = try await URLSession.shared.data(from: blockUrl) - // Validate HTTP response - guard let httpBlockResponse = blockResponse as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - guard httpBlockResponse.statusCode == 200 else { + guard let httpBlockResponse = blockResponse as? HTTPURLResponse, + httpBlockResponse.statusCode == 200 + else { throw URLError(.badServerResponse) } - do { - let decoder = JSONDecoder() - let blockInfo = try decoder.decode(BlockInfo.self, from: blockData) - let formattedData = formatBlockInfo(blockInfo) + let blockInfo = try JSONDecoder().decode(BlockInfo.self, from: blockData) + let formattedData = formatBlockInfo(blockInfo) - // Cache the data - cacheData(formattedData) + cacheData(formattedData) + BlocksHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() - return formattedData - } catch { - throw error - } + return formattedData } - /// Caches block data to UserDefaults - /// - Parameter data: Block data to cache - func cacheData(_ data: BlockData) { - do { - let encoder = JSONEncoder() - let encoded = try encoder.encode(data) - cache.set(encoded, forKey: cacheKey) - } catch { - // Handle silently - } + /// Caches block data to the App Group so the WidgetKit extension can read it. + func cacheData(_ data: CachedBlock) { + BlocksWidgetCache.saveLatest(data) } - /// Retrieves cached block data - /// - Returns: Block data if available - func getCachedData() -> BlockData? { - guard let data = cache.data(forKey: cacheKey) else { - return nil - } - - do { - let decoder = JSONDecoder() - return try decoder.decode(BlockData.self, from: data) - } catch { - return nil - } + /// Retrieves cached block data from the App Group. + func getCachedData() -> CachedBlock? { + BlocksWidgetCache.loadLatest() } - /// Formats raw block info into display-friendly format - /// - Parameter blockInfo: Raw block info from API - /// - Returns: Formatted block data - private func formatBlockInfo(_ blockInfo: BlockInfo) -> BlockData { + /// Formats raw block info into display-friendly format. + private func formatBlockInfo(_ blockInfo: BlockInfo) -> CachedBlock { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.locale = Locale.current - let difficulty = (blockInfo.difficulty / 1_000_000_000_000).formatted(.number.precision(.fractionLength(2))) - let size = Double(blockInfo.size) / 1024 - let weight = Double(blockInfo.weight) / 1024 / 1024 + let sizeKb = Double(blockInfo.size) / 1024 let timeFormatter = DateFormatter() timeFormatter.dateStyle = .none @@ -138,25 +103,24 @@ class BlocksService { let dateString = dateFormatter.string(from: date) let formattedHeight = formatter.string(from: NSNumber(value: blockInfo.height)) ?? "\(blockInfo.height)" - let formattedSize = "\(formatter.string(from: NSNumber(value: Int(size))) ?? "\(Int(size))") KB" + let formattedSize = "\(formatter.string(from: NSNumber(value: Int(sizeKb))) ?? "\(Int(sizeKb))") KB" let formattedTransactions = formatter.string(from: NSNumber(value: blockInfo.txCount)) ?? "\(blockInfo.txCount)" - let formattedWeight = "\(formatter.string(from: NSNumber(value: weight)) ?? "\(weight)") MWU" - return BlockData( - hash: blockInfo.id, - difficulty: difficulty, - size: formattedSize, - weight: formattedWeight, + let totalFeesSats = blockInfo.extras?.totalFees ?? 0 + let formattedFees = formatter.string(from: NSNumber(value: totalFeesSats)) ?? "\(totalFeesSats)" + + return CachedBlock( height: formattedHeight, time: time, date: dateString, transactionCount: formattedTransactions, - merkleRoot: blockInfo.merkleRoot + size: formattedSize, + fees: formattedFees ) } } -/// Raw block info model from mempool.space API +/// Raw block info model from mempool.space API (`/api/v1/block/:hash`). struct BlockInfo: Codable { let id: String let height: Int @@ -164,8 +128,11 @@ struct BlockInfo: Codable { let txCount: Int let size: Int let weight: Int - let difficulty: Double - let merkleRoot: String + let extras: Extras? + + struct Extras: Codable { + let totalFees: Int? + } enum CodingKeys: String, CodingKey { case id @@ -174,20 +141,6 @@ struct BlockInfo: Codable { case txCount = "tx_count" case size case weight - case difficulty - case merkleRoot = "merkle_root" + case extras } } - -/// Formatted block data for display -struct BlockData: Codable { - let hash: String - let difficulty: String - let size: String - let weight: String - let height: String - let time: String - let date: String - let transactionCount: String - let merkleRoot: String -} diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 6d0e392c8..32fe891da 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -127,10 +127,7 @@ enum WidgetsBackupConverter { date: prefs["showDate"] as? Bool ?? true, transactionCount: prefs["showTransactions"] as? Bool ?? false, size: prefs["showSize"] as? Bool ?? false, - weight: false, - difficulty: false, - hash: false, - merkleRoot: false, + fees: prefs["showFees"] as? Bool ?? false, showSource: prefs["showSource"] as? Bool ?? false ) optionsData = try? JSONEncoder().encode(iosOptions) @@ -201,6 +198,7 @@ enum WidgetsBackupConverter { "showDate": defaults.date, "showTransactions": defaults.transactionCount, "showSize": defaults.size, + "showFees": defaults.fees, "showSource": defaults.showSource, ] } diff --git a/Bitkit/ViewModels/Widgets/BlocksViewModel.swift b/Bitkit/ViewModels/Widgets/BlocksViewModel.swift index 2bbf1b0ad..be29b597b 100644 --- a/Bitkit/ViewModels/Widgets/BlocksViewModel.swift +++ b/Bitkit/ViewModels/Widgets/BlocksViewModel.swift @@ -6,7 +6,7 @@ import SwiftUI class BlocksViewModel: ObservableObject { static let shared = BlocksViewModel() - @Published var blockData: BlockData? + @Published var blockData: CachedBlock? @Published var isLoading = false @Published var error: Error? diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 3a29f381e..f49604026 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -240,6 +240,10 @@ class WidgetsViewModel: ObservableObject { return newsOptions } + if type == .blocks, let blocksOptions = BlocksHomeScreenWidgetOptionsStore.load() as? T { + return blocksOptions + } + // Return default options if none saved return getDefaultOptions(for: type) as! T } @@ -311,6 +315,7 @@ class WidgetsViewModel: ObservableObject { } syncPriceOptionsToHomeScreenWidget() syncNewsOptionsToHomeScreenWidget() + syncBlocksOptionsToHomeScreenWidget() } private func persistSavedWidgets() { @@ -322,6 +327,7 @@ class WidgetsViewModel: ObservableObject { } syncPriceOptionsToHomeScreenWidget() syncNewsOptionsToHomeScreenWidget() + syncBlocksOptionsToHomeScreenWidget() } /// Keeps the home-screen WidgetKit price widget in sync with in-app price widget options (App Group). @@ -337,4 +343,11 @@ class WidgetsViewModel: ObservableObject { NewsHomeScreenWidgetOptionsStore.save(options) NewsHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } + + /// Keeps the home-screen WidgetKit Blocks widget in sync with in-app Blocks widget options (App Group). + private func syncBlocksOptionsToHomeScreenWidget() { + let options: BlocksWidgetOptions = getOptions(for: .blocks, as: BlocksWidgetOptions.self) + BlocksHomeScreenWidgetOptionsStore.save(options) + BlocksHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() + } } diff --git a/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift b/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift new file mode 100644 index 000000000..f6805fdf4 --- /dev/null +++ b/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift @@ -0,0 +1,247 @@ +import SwiftUI + +/// Preview screen for the Bitcoin Blocks widget. +struct BlocksWidgetPreviewView: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + + @StateObject private var viewModel = BlocksViewModel.shared + + @State private var carouselPage: Int = 0 + @State private var showDeleteAlert = false + + private let widgetType: WidgetType = .blocks + + private var widgetName: String { + t("widgets__blocks__name") + } + + private var widgetDescription: String { + t("widgets__blocks__description") + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(widgetType) + } + + private var hasCustomOptions: Bool { + widgets.hasCustomOptions(for: widgetType) + } + + private var currentOptions: BlocksWidgetOptions { + widgets.getOptions(for: widgetType, as: BlocksWidgetOptions.self) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + NavigationBar(title: widgetName, showMenuButton: false, showGradient: false) + + VStack(alignment: .leading, spacing: 0) { + BodyMText(widgetDescription, textColor: .textSecondary) + .padding(.bottom, 16) + + Divider().background(Color.white.opacity(0.1)) + + widgetSettingsRow + + Divider().background(Color.white.opacity(0.1)) + } + + VStack(spacing: 16) { + Spacer(minLength: 0) + + carousel + + Spacer(minLength: 0) + + sizeLabel + + pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + buttonsRow + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray7.ignoresSafeArea()) + .task { + viewModel.startUpdates() + } + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteAlert, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } + Button(t("common__delete_yes"), role: .destructive) { onDelete() } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": widgetName])) + } + ) + } + + // MARK: - Widget Settings cell + + private var widgetSettingsRow: some View { + Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { + HStack(alignment: .center, spacing: 0) { + BodyMText(t("widgets__blocks__widget_settings"), textColor: .textPrimary) + + Spacer() + + BodyMText( + hasCustomOptions + ? t("widgets__widget__edit_custom") + : t("widgets__widget__edit_default"), + textColor: .textSecondary + ) + + Image("chevron") + .resizable() + .foregroundColor(.textSecondary) + .frame(width: 24, height: 24) + .padding(.leading, 5) + } + .frame(maxWidth: .infinity, minHeight: 51) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("WidgetEdit") + } + + // MARK: - Carousel + + private var carousel: some View { + TabView(selection: $carouselPage) { + compactPage.tag(0) + widePage.tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(height: 320) + } + + private var compactPage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = viewModel.blockData { + BlocksWidgetCompactContent(data: data, options: currentOptions) + } else { + placeholderCompact + } + } + .frame(width: 163, height: 192) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private var widePage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = viewModel.blockData { + BlocksWidgetWideContent(data: data, options: currentOptions) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + placeholderWide + } + } + .frame(maxWidth: .infinity) + Spacer(minLength: 0) + } + } + + private var placeholderCompact: some View { + Color.gray6 + .cornerRadius(16) + .overlay(ProgressView()) + } + + private var placeholderWide: some View { + Color.gray6 + .cornerRadius(16) + .frame(height: 180) + .overlay(ProgressView()) + } + + // MARK: - Size label & page indicator + + private var sizeLabel: some View { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__blocks__size_small") + : t("widgets__blocks__size_wide"), + textColor: .textSecondary + ) + .textCase(.uppercase) + Spacer() + } + } + + private var pageIndicator: some View { + HStack(spacing: 8) { + Spacer() + ForEach(0 ..< 2, id: \.self) { index in + Circle() + .fill(carouselPage == index ? Color.brandAccent : Color.white.opacity(0.32)) + .frame(width: 8, height: 8) + } + Spacer() + } + } + + // MARK: - Buttons + + private var buttonsRow: some View { + HStack(spacing: 16) { + if isWidgetSaved { + CustomButton( + title: t("common__delete"), + variant: .secondary, + size: .large, + shouldExpand: true + ) { + showDeleteAlert = true + } + .accessibilityIdentifier("WidgetDelete") + } + + CustomButton( + title: t("widgets__widget__save_widget"), + variant: .primary, + size: .large, + shouldExpand: true, + action: onSave + ) + .accessibilityIdentifier("WidgetSave") + } + } + + // MARK: - Actions + + private func onSave() { + widgets.saveWidget(widgetType) + navigation.reset() + } + + private func onDelete() { + widgets.deleteWidget(widgetType) + navigation.reset() + } +} + +#Preview { + NavigationStack { + BlocksWidgetPreviewView() + .environmentObject(NavigationViewModel()) + .environmentObject(WidgetsViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index e6b806683..b15fd90cf 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -34,9 +34,13 @@ class WidgetEditLogic: ObservableObject { var hasEnabledOption: Bool { switch widgetType { case .blocks: - // Blocks widget has many options, check if any are enabled - return blocksOptions.height || blocksOptions.time || blocksOptions.date || blocksOptions.transactionCount || blocksOptions.size - || blocksOptions.weight || blocksOptions.difficulty || blocksOptions.hash || blocksOptions.merkleRoot || blocksOptions.showSource + return blocksOptions.height + || blocksOptions.time + || blocksOptions.date + || blocksOptions.transactionCount + || blocksOptions.size + || blocksOptions.fees + || blocksOptions.showSource case .news, .facts: // Static items (showTitle) are always enabled, so these widgets always have enabled options return true @@ -92,14 +96,8 @@ class WidgetEditLogic: ObservableObject { blocksOptions.transactionCount.toggle() case "size": blocksOptions.size.toggle() - case "weight": - blocksOptions.weight.toggle() - case "difficulty": - blocksOptions.difficulty.toggle() - case "hash": - blocksOptions.hash.toggle() - case "merkleRoot": - blocksOptions.merkleRoot.toggle() + case "fees": + blocksOptions.fees.toggle() case "showSource": blocksOptions.showSource.toggle() default: diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index b7a30e3e6..65e875dd7 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -68,205 +68,41 @@ enum WidgetEditItemFactory { ) -> [WidgetEditItem] { var items: [WidgetEditItem] = [] - if let data = blocksViewModel.blockData { - items.append( - WidgetEditItem( - key: "height", - type: .toggleItem, - title: "Block", - value: data.height, - isChecked: blocksOptions.height - ) - ) - - items.append( - WidgetEditItem( - key: "time", - type: .toggleItem, - title: "Time", - value: data.time, - isChecked: blocksOptions.time - ) - ) - - items.append( - WidgetEditItem( - key: "date", - type: .toggleItem, - title: "Date", - value: data.date, - isChecked: blocksOptions.date - ) - ) - - items.append( - WidgetEditItem( - key: "transactionCount", - type: .toggleItem, - title: "Transactions", - value: data.transactionCount, - isChecked: blocksOptions.transactionCount - ) - ) - - items.append( - WidgetEditItem( - key: "size", - type: .toggleItem, - title: "Size", - value: data.size, - isChecked: blocksOptions.size - ) - ) - - items.append( - WidgetEditItem( - key: "weight", - type: .toggleItem, - title: "Weight", - value: data.weight, - isChecked: blocksOptions.weight - ) - ) - - items.append( - WidgetEditItem( - key: "difficulty", - type: .toggleItem, - title: "Difficulty", - value: data.difficulty, - isChecked: blocksOptions.difficulty - ) - ) - - items.append( - WidgetEditItem( - key: "hash", - type: .toggleItem, - title: "Hash", - value: data.hash, - isChecked: blocksOptions.hash - ) - ) - - items.append( - WidgetEditItem( - key: "merkleRoot", - type: .toggleItem, - title: "Merkle Root", - value: data.merkleRoot, - isChecked: blocksOptions.merkleRoot - ) - ) - - items.append( - WidgetEditItem( - key: "showSource", - type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("mempool.space", textColor: .textSecondary)), - isChecked: blocksOptions.showSource - ) - ) - } else { - // Fallback when no data is available - items.append( - WidgetEditItem( - key: "height", - type: .toggleItem, - title: "Block", - value: "870,123", - isChecked: blocksOptions.height - ) - ) - - items.append( - WidgetEditItem( - key: "time", - type: .toggleItem, - title: "Time", - value: "2:45:30 PM", - isChecked: blocksOptions.time - ) - ) - - items.append( - WidgetEditItem( - key: "date", - type: .toggleItem, - title: "Date", - value: "Dec 15, 2024", - isChecked: blocksOptions.date - ) - ) - - items.append( - WidgetEditItem( - key: "transactionCount", - type: .toggleItem, - title: "Transactions", - value: "3,456", - isChecked: blocksOptions.transactionCount - ) - ) - - items.append( - WidgetEditItem( - key: "size", - type: .toggleItem, - title: "Size", - value: "1,234 KB", - isChecked: blocksOptions.size - ) - ) - - items.append( - WidgetEditItem( - key: "weight", - type: .toggleItem, - title: "Weight", - value: "3.45 MWU", - isChecked: blocksOptions.weight - ) - ) - - items.append( - WidgetEditItem( - key: "difficulty", - type: .toggleItem, - title: "Difficulty", - value: "102.45 T", - isChecked: blocksOptions.difficulty - ) - ) - - items.append( - WidgetEditItem( - key: "hash", - type: .toggleItem, - title: "Hash", - value: "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054", - isChecked: blocksOptions.hash - ) - ) - - items.append( - WidgetEditItem( - key: "merkleRoot", - type: .toggleItem, - title: "Merkle Root", - value: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", - isChecked: blocksOptions.merkleRoot - ) - ) - - items.append( - WidgetEditItem( - key: "showSource", - type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("mempool.space", textColor: .textSecondary)), - isChecked: blocksOptions.showSource + items.append(sectionHeaderItem(key: "blocks_data_header", title: t("widgets__blocks__data_header"))) + + let fallback: [BlocksWidgetField: String] = [ + .height: "870,123", + .time: "2:45:30 PM", + .date: "Dec 15, 2024", + .transactionCount: "3,456", + .size: "1,234 KB", + .fees: "25,059,357", + ] + + for field in BlocksWidgetField.allCases { + let value: String = { + if field == .showSource { return "mempool.space" } + if let data = blocksViewModel.blockData { return field.value(from: data) } + return fallback[field] ?? "" + }() + + let titleView = AnyView( + HStack(spacing: 8) { + Image(field.iconName) + .resizable() + .renderingMode(.template) + .foregroundColor(.brandAccent) + .frame(width: 20, height: 20) + BodySSBText(field.inAppLabel, textColor: .textSecondary) + } + ) + items.append( + WidgetEditItem( + key: field.rawValue, + type: .toggleItem, + titleView: titleView, + valueView: AnyView(BodySSBText(value, textColor: .textSecondary)), + isChecked: field.isEnabled(in: blocksOptions) ) ) } diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index 4c3e81a88..19e98cbb6 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -55,16 +55,22 @@ struct WidgetEditView: View { editLogic?.resetOptions() } + /// v61 widget configuration screens (Price, News, Blocks) use the widget name as the title + /// and skip the legacy description block. + private var usesV61Header: Bool { + id == .price || id == .blocks + } + var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar( - title: id == .price ? widget.name : t("widgets__widget__edit"), - showMenuButton: id != .price, - showGradient: id != .price + title: usesV61Header ? widget.name : t("widgets__widget__edit"), + showMenuButton: !usesV61Header, + showGradient: !usesV61Header ) .padding(.bottom, 16) - if id != .price { + if !usesV61Header { BodyMText( t("widgets__widget__edit_description", variables: ["name": widget.name]), textColor: .textSecondary diff --git a/BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json b/BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json new file mode 100644 index 000000000..cba1e6c79 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "calendar.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/calendar.imageset/calendar.pdf b/BitkitWidget/Assets.xcassets/calendar.imageset/calendar.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4beb7cb14838b528bffa86ea3a2b9cc1292b30ee GIT binary patch literal 4026 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 literal 0 HcmV?d00001 diff --git a/BitkitWidget/Assets.xcassets/clock.imageset/Contents.json b/BitkitWidget/Assets.xcassets/clock.imageset/Contents.json new file mode 100644 index 000000000..0397df2e7 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/clock.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "clock.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/clock.imageset/clock.pdf b/BitkitWidget/Assets.xcassets/clock.imageset/clock.pdf new file mode 100644 index 0000000000000000000000000000000000000000..bf69f0658c14e25b8944fa1e0f97af9e26797f85 GIT binary patch literal 3922 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 GY5xblm%ZEo literal 0 HcmV?d00001 diff --git a/BitkitWidget/Assets.xcassets/coins.imageset/Contents.json b/BitkitWidget/Assets.xcassets/coins.imageset/Contents.json new file mode 100644 index 000000000..7ca0a2a25 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/coins.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "coins.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/coins.imageset/coins.pdf b/BitkitWidget/Assets.xcassets/coins.imageset/coins.pdf new file mode 100644 index 0000000000000000000000000000000000000000..88f4fd8183a883cf86ceebeee4217e1d685d65c7 GIT binary patch literal 11668 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 D0#_a3 literal 0 HcmV?d00001 diff --git a/BitkitWidget/Assets.xcassets/globe.imageset/Contents.json b/BitkitWidget/Assets.xcassets/globe.imageset/Contents.json new file mode 100644 index 000000000..fbc26e51a --- /dev/null +++ b/BitkitWidget/Assets.xcassets/globe.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "globe.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/globe.imageset/globe.pdf b/BitkitWidget/Assets.xcassets/globe.imageset/globe.pdf new file mode 100644 index 0000000000000000000000000000000000000000..08a6eddc11869f6e3dd38116f8f0d15382a8048f GIT binary patch literal 4496 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 J&#N0~{0Cjz-l+fp literal 0 HcmV?d00001 diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift index ba9bbdd06..48d2778c4 100644 --- a/BitkitWidget/BitkitWidget.swift +++ b/BitkitWidget/BitkitWidget.swift @@ -6,5 +6,6 @@ struct BitkitWidgetBundle: WidgetBundle { var body: some Widget { BitkitPriceWidget() BitkitNewsWidget() + BitkitBlocksWidget() } } diff --git a/BitkitWidget/BlocksHomeScreenWidget.swift b/BitkitWidget/BlocksHomeScreenWidget.swift new file mode 100644 index 000000000..40af64ad5 --- /dev/null +++ b/BitkitWidget/BlocksHomeScreenWidget.swift @@ -0,0 +1,216 @@ +import SwiftUI +import WidgetKit + +// MARK: - Entry + +struct BlocksWidgetEntry: TimelineEntry { + let date: Date + let block: CachedBlock? + let options: BlocksWidgetOptions + /// True when no fresh data could be fetched and there is nothing in cache to fall back to. + let showsError: Bool +} + +// MARK: - Helpers + +private enum BlocksWidgetEntryBuilder { + static let refreshInterval: TimeInterval = 15 * 60 +} + +// MARK: - Timeline Provider + +struct BlocksWidgetProvider: TimelineProvider { + /// Stable mock for widget gallery / placeholder snapshots. + private static let mockBlock = CachedBlock( + height: "870,123", + time: "01:31:42 UTC", + date: "11/2/2024", + transactionCount: "2,175", + size: "1,606 KB", + fees: "25,059,357" + ) + + private static let mockEntry = BlocksWidgetEntry( + date: Date(), + block: mockBlock, + options: BlocksWidgetOptions(), + showsError: false + ) + + func placeholder(in _: Context) -> BlocksWidgetEntry { + Self.mockEntry + } + + func getSnapshot(in context: Context, completion: @escaping (BlocksWidgetEntry) -> Void) { + let options = BlocksHomeScreenWidgetOptionsStore.load() + + if context.isPreview { + completion(BlocksWidgetEntry( + date: Self.mockEntry.date, + block: Self.mockBlock, + options: options, + showsError: false + )) + return + } + + let cached = BlocksWidgetService.cachedLatest() + completion(BlocksWidgetEntry( + date: Date(), + block: cached, + options: options, + showsError: false + )) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let options = BlocksHomeScreenWidgetOptionsStore.load() + + Task { + let entry: BlocksWidgetEntry + do { + let fresh = try await BlocksWidgetService.fetchFreshLatest() + entry = BlocksWidgetEntry(date: Date(), block: fresh, options: options, showsError: false) + } catch { + if let cached = BlocksWidgetService.cachedLatest() { + entry = BlocksWidgetEntry(date: Date(), block: cached, options: options, showsError: false) + } else { + entry = BlocksWidgetEntry(date: Date(), block: nil, options: options, showsError: true) + } + } + + let nextRefresh = Date().addingTimeInterval(BlocksWidgetEntryBuilder.refreshInterval) + completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + } + } +} + +// MARK: - View + +struct BlocksHomeScreenWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode + + var entry: BlocksWidgetProvider.Entry + + var body: some View { + content + .containerBackground(for: .widget) { backgroundView } + } + + @ViewBuilder + private var content: some View { + if entry.showsError { + errorView + } else if let block = entry.block { + switch widgetFamily { + case .systemSmall: + compactLayout(block: block) + case .systemLarge: + wideLayout(block: block, fields: entry.options.enabledFields) + default: + wideLayout(block: block, fields: entry.options.compactFields) + } + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + + // MARK: - Layouts + + /// Compact (`.systemSmall`): icon + value rows, capped at 4. Default-selected fields + /// take priority; remaining slots are filled by extras in declared order. + private func compactLayout(block: CachedBlock) -> some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(entry.options.compactFields, id: \.self) { field in + HStack(alignment: .center, spacing: 8) { + iconImage(field: field) + Text(field.value(from: block)) + .font(Fonts.semiBold(size: 15)) + .foregroundColor(titleTextColor) + .lineLimit(1) + .truncationMode(.middle) + .widgetAccentable() + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + /// Wide layout: icon + label + value rows. `.systemMedium` is capped at 4 fields with the + /// same default-priority logic as the small widget; `.systemLarge` shows all enabled fields. + private func wideLayout(block: CachedBlock, fields: [BlocksWidgetField]) -> some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(fields, id: \.self) { field in + HStack(alignment: .center, spacing: 8) { + iconImage(field: field) + Text(field.label) + .font(Fonts.regular(size: 17)) + .foregroundColor(labelTextColor) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + Text(field.value(from: block)) + .font(Fonts.semiBold(size: 17)) + .foregroundColor(titleTextColor) + .lineLimit(1) + .truncationMode(.middle) + .widgetAccentable() + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func iconImage(field: BlocksWidgetField) -> some View { + Image(field.iconName) + .resizable() + .renderingMode(.template) + .foregroundColor(iconColor) + .frame(width: 20, height: 20) + .widgetAccentable() + } + + private var errorView: some View { + Text("Couldn’t load blocks data.") + .font(Fonts.regular(size: 13)) + .foregroundColor(labelTextColor) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Colors + + private var backgroundView: some View { + widgetRenderingMode == .fullColor ? Color.gray6 : Color.clear + } + + private var titleTextColor: Color { + widgetRenderingMode == .fullColor ? .white : .primary + } + + private var labelTextColor: Color { + widgetRenderingMode == .fullColor ? .white.opacity(0.8) : .secondary + } + + private var iconColor: Color { + widgetRenderingMode == .fullColor ? .brandAccent : .primary + } +} + +// MARK: - Widget Configuration + +struct BitkitBlocksWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration( + kind: BlocksHomeScreenWidgetOptionsStore.blocksHomeScreenWidgetKind, + provider: BlocksWidgetProvider() + ) { entry in + BlocksHomeScreenWidgetEntryView(entry: entry) + } + .configurationDisplayName("Bitcoin Blocks") + .description("Latest mined Bitcoin block, mirroring the in-app blocks widget.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} diff --git a/BitkitWidget/BlocksWidgetService.swift b/BitkitWidget/BlocksWidgetService.swift new file mode 100644 index 000000000..59b5a1dbe --- /dev/null +++ b/BitkitWidget/BlocksWidgetService.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Slim Bitcoin Blocks fetcher used inside the WidgetKit extension. +/// +/// Reads the latest `CachedBlock` from the App Group (written by the main app's `BlocksService`) +/// and falls back to a direct mempool.space fetch when the cache is empty. The cache itself is +/// owned by the main app; this service intentionally does not write back to it. +enum BlocksWidgetService { + enum FetchError: Error { + case invalidURL + case unexpectedResponse + case missingData + } + + private static let baseUrl = "https://mempool.space/api" + + static func cachedLatest() -> CachedBlock? { + BlocksWidgetCache.loadLatest() + } + + static func fetchFreshLatest() async throws -> CachedBlock { + guard let tipUrl = URL(string: "\(baseUrl)/blocks/tip/hash") else { + throw FetchError.invalidURL + } + + let (hashData, hashResponse) = try await URLSession.shared.data(from: tipUrl) + guard let httpResponse = hashResponse as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw FetchError.unexpectedResponse + } + + let hash = String(data: hashData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + guard let blockUrl = URL(string: "\(baseUrl)/v1/block/\(hash)") else { + throw FetchError.invalidURL + } + + let (blockData, blockResponse) = try await URLSession.shared.data(from: blockUrl) + guard let httpBlockResponse = blockResponse as? HTTPURLResponse, httpBlockResponse.statusCode == 200 else { + throw FetchError.unexpectedResponse + } + + let info = try JSONDecoder().decode(WireBlock.self, from: blockData) + return Self.format(info) + } + + private static func format(_ info: WireBlock) -> CachedBlock { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = Locale.current + + let sizeKb = Double(info.size) / 1024 + + let timeFormatter = DateFormatter() + timeFormatter.dateStyle = .none + timeFormatter.timeStyle = .medium + + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .short + dateFormatter.timeStyle = .none + + let date = Date(timeIntervalSince1970: TimeInterval(info.timestamp)) + + let formattedHeight = formatter.string(from: NSNumber(value: info.height)) ?? "\(info.height)" + let formattedSize = "\(formatter.string(from: NSNumber(value: Int(sizeKb))) ?? "\(Int(sizeKb))") KB" + let formattedTransactions = formatter.string(from: NSNumber(value: info.txCount)) ?? "\(info.txCount)" + let totalFees = info.extras?.totalFees ?? 0 + let formattedFees = formatter.string(from: NSNumber(value: totalFees)) ?? "\(totalFees)" + + return CachedBlock( + height: formattedHeight, + time: timeFormatter.string(from: date), + date: dateFormatter.string(from: date), + transactionCount: formattedTransactions, + size: formattedSize, + fees: formattedFees + ) + } +} + +// MARK: - Wire models + +/// Local mirror of the mempool `/api/v1/block/:hash` payload — kept private so the extension +/// stays small and decoupled from the main app's `BlockInfo`. +private struct WireBlock: Codable { + let id: String + let height: Int + let timestamp: Int + let txCount: Int + let size: Int + let weight: Int + let extras: WireExtras? + + enum CodingKeys: String, CodingKey { + case id + case height + case timestamp + case txCount = "tx_count" + case size + case weight + case extras + } +} + +private struct WireExtras: Codable { + let totalFees: Int? +} diff --git a/changelog.d/next/blocks-widget-v61.added.md b/changelog.d/next/blocks-widget-v61.added.md new file mode 100644 index 000000000..f4c8ddcf4 --- /dev/null +++ b/changelog.d/next/blocks-widget-v61.added.md @@ -0,0 +1 @@ +Bitcoin Blocks home-screen widget and v61 in-app redesign. From 2469d3b1bfe12b9a37fd28576d8875c747042e39 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 10:36:07 -0300 Subject: [PATCH 2/3] fix: use arrow-up-down for transfer icons --- Bitkit/Models/BlocksWidgetFields.swift | 2 +- .../Contents.json | 2 +- .../arrow-up-down.pdf} | Bin 3957 -> 4034 bytes 3 files changed, 2 insertions(+), 2 deletions(-) rename BitkitWidget/Assets.xcassets/{transfer.imageset => arrow-up-down.imageset}/Contents.json (82%) rename BitkitWidget/Assets.xcassets/{transfer.imageset/transfer.pdf => arrow-up-down.imageset/arrow-up-down.pdf} (83%) diff --git a/Bitkit/Models/BlocksWidgetFields.swift b/Bitkit/Models/BlocksWidgetFields.swift index 31272ccfd..8c09b0465 100644 --- a/Bitkit/Models/BlocksWidgetFields.swift +++ b/Bitkit/Models/BlocksWidgetFields.swift @@ -38,7 +38,7 @@ enum BlocksWidgetField: String, CaseIterable { case .height: return "cube" case .time: return "clock" case .date: return "calendar" - case .transactionCount: return "transfer" + case .transactionCount: return "arrow-up-down" case .size: return "file-text" case .fees: return "coins" case .showSource: return "globe" diff --git a/BitkitWidget/Assets.xcassets/transfer.imageset/Contents.json b/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/Contents.json similarity index 82% rename from BitkitWidget/Assets.xcassets/transfer.imageset/Contents.json rename to BitkitWidget/Assets.xcassets/arrow-up-down.imageset/Contents.json index bf9fbdf44..ae0bb361f 100644 --- a/BitkitWidget/Assets.xcassets/transfer.imageset/Contents.json +++ b/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "transfer.pdf", + "filename" : "arrow-up-down.pdf", "idiom" : "universal" } ], diff --git a/BitkitWidget/Assets.xcassets/transfer.imageset/transfer.pdf b/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/arrow-up-down.pdf similarity index 83% rename from BitkitWidget/Assets.xcassets/transfer.imageset/transfer.pdf rename to BitkitWidget/Assets.xcassets/arrow-up-down.imageset/arrow-up-down.pdf index 667ed4009bb7f5966e79171ddf58f4b5d1a5ad91..34bc06e510810c1bf25816d90acad3497e127e1b 100644 GIT binary patch delta 646 zcmew=cSwGNEqDFeV9PueL!L8VyZXd8tZCkrv_D~^e@4Q_gKHQM|7VX`awF>;V+E6O zM(Fe-Li``U>gMIw-_f4-_j2uy7tJ@s_&#j9aBDHMO8f?fH~(Wl=!#0cnVFq2m%&ZK z_QJQF`;(*lkEtr#t#$Kivtcx2-+FP~3a5?Nw!JkA(6W8LCst>=Q@y7m^9xpOPLBk^ z27R6=i+M%*f1mI~c>QQ)d{O&`ohglJcg)0D3@*$5%~X@wz%b{^yMH_P@4vJA_um|O zF^PFjr(FEMDE{`Cx+3ppzfkZKvy}Oo+b&#;xR=8@d+QgIMX6OMm!>d$y&Y?7amQe1 zceY)?`;86ROL<<+PSog+Ii2>WUMSF|G}1fo$%da6u5%`>XiK$_IFk40Q{agwzd4o` z@Fcz}epc0Id9nXN2=CXO7x{zZrOf`89Zx-;_r%+@#yhj`Sg`iNiJa+LwHsyYBJVcO znZePW`#jA@_^9r>kp1sJH9uFiv*EcpflHz9?9T|@rTtaD($hWS|IadecC82K@p^uk z&+V2c7P9H6{yD_(#qJw3qj6(l^uYr&uCOQ=eLZjTuz~f#l6A}8>U_UceWSZ*!K>35 zTd$}`75?X`%X#pj>^=LsySy@rcb=;ATX#;ob0YC`;iDxgt4syX6c}{e{joOOg1yh8 z?RKO0p-ww{{!r)Iyf0^OthnFYs4V*Os3XD?xoq#aTf2i}Co}TOGMgG3PFCa%k|5a{-EqDFf$+meihCHp+bCX^%O#7<8>KkX1jHAPZq+Ep$e;aMGEJVclCgt9l zc~)Tl3HL53yZuKVKW(o*mcDQIeZ5~B-f&F+yky?It*0+8{`sou{mN+v4Yprcc|bBT zK;!uDn%jpoWAAAvtkn!OEwHv&66n6uxv}Wn(;0q>tB$Z)=S`a`;Z>iJ5y_RYd_u&w zlP<59ev$K?Qs{D0VCC^j27}8t-twJdRVaG(K3geqrsL(mMfUr5nt%U$vHXTg^_iW5 zwjTt}E}xk3WO4H{A@+LdDAO?=8|+JFyAxED(F3q3S(9StZ{}&ff2>sM)3NtYzntlXe^`DKP(i$zOi{qbzht1G<+%ZWpsQ)uV!m^F^xpVib3Eg;O@ormQfArUXzr;!^pFCb+xr}|@ zqsRFlwwA{8O5GiA0a|3iCQxju!Au~hs$r=2CoMz@`T&k+B{%!yuegSX* From 442ea604e9f1df4c223627cc01b18fc089e3762f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 10:59:48 -0300 Subject: [PATCH 3/3] fix: drop large OS widget support --- BitkitWidget/BlocksHomeScreenWidget.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/BitkitWidget/BlocksHomeScreenWidget.swift b/BitkitWidget/BlocksHomeScreenWidget.swift index 40af64ad5..17ab544da 100644 --- a/BitkitWidget/BlocksHomeScreenWidget.swift +++ b/BitkitWidget/BlocksHomeScreenWidget.swift @@ -106,8 +106,6 @@ struct BlocksHomeScreenWidgetEntryView: View { switch widgetFamily { case .systemSmall: compactLayout(block: block) - case .systemLarge: - wideLayout(block: block, fields: entry.options.enabledFields) default: wideLayout(block: block, fields: entry.options.compactFields) } @@ -139,8 +137,8 @@ struct BlocksHomeScreenWidgetEntryView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - /// Wide layout: icon + label + value rows. `.systemMedium` is capped at 4 fields with the - /// same default-priority logic as the small widget; `.systemLarge` shows all enabled fields. + /// Wide layout (`.systemMedium`): icon + label + value rows, capped at 4 fields with the + /// same default-priority logic as the small widget. private func wideLayout(block: CachedBlock, fields: [BlocksWidgetField]) -> some View { VStack(alignment: .leading, spacing: 12) { ForEach(fields, id: \.self) { field in @@ -211,6 +209,6 @@ struct BitkitBlocksWidget: Widget { } .configurationDisplayName("Bitcoin Blocks") .description("Latest mined Bitcoin block, mirroring the in-app blocks widget.") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .supportedFamilies([.systemSmall, .systemMedium]) } }