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..8c09b0465 --- /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 "arrow-up-down" + 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 951a56c0e..5251574b6 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1408,6 +1408,7 @@ "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__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 d921ab7ed..b54c49b27 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -262,6 +262,10 @@ class WidgetsViewModel: ObservableObject { if type == .news, let newsOptions = options as? NewsWidgetOptions { syncNewsOptionsToHomeScreenWidget(newsOptions) } + + if type == .blocks, let blocksOptions = options as? BlocksWidgetOptions { + syncBlocksOptionsToHomeScreenWidget(blocksOptions) + } } catch { print("Failed to save widget options: \(error)") } @@ -335,4 +339,12 @@ class WidgetsViewModel: ObservableObject { NewsHomeScreenWidgetOptionsStore.save(options) NewsHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } + + /// Mirrors in-app blocks widget options to the App Group so the home-screen WidgetKit widget can read them. + /// Only invoked when the user explicitly changes blocks widget options — adding, deleting, or resetting + /// in-app widgets must not affect the independent OS home-screen widget. + private func syncBlocksOptionsToHomeScreenWidget(_ options: BlocksWidgetOptions) { + BlocksHomeScreenWidgetOptionsStore.save(options) + BlocksHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() + } } diff --git a/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift b/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift new file mode 100644 index 000000000..710354853 --- /dev/null +++ b/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift @@ -0,0 +1,243 @@ +import SwiftUI + +/// Preview screen for the Bitcoin Blocks widget. +struct BlocksWidgetPreviewView: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + + @StateObject private var viewModel = BlocksViewModel.shared + + @State private var carouselPage: Int = 0 + @State private var showDeleteAlert = false + + private let widgetType: WidgetType = .blocks + + private var widgetName: String { + t("widgets__blocks__name") + } + + private var widgetDescription: String { + t("widgets__blocks__description") + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(widgetType) + } + + private var hasCustomOptions: Bool { + widgets.hasCustomOptions(for: widgetType) + } + + private var currentOptions: BlocksWidgetOptions { + widgets.getOptions(for: widgetType, as: BlocksWidgetOptions.self) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + NavigationBar(title: widgetName, showMenuButton: false) + + VStack(alignment: .leading, spacing: 0) { + BodyMText(widgetDescription, textColor: .textSecondary) + .padding(.bottom, 16) + + Divider().background(Color.white.opacity(0.1)) + + widgetSettingsRow + + Divider().background(Color.white.opacity(0.1)) + } + + VStack(spacing: 16) { + carousel + + sizeLabel + + pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + buttonsRow + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .task { + viewModel.startUpdates() + } + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteAlert, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } + Button(t("common__delete_yes"), role: .destructive) { onDelete() } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": widgetName])) + } + ) + } + + // MARK: - Widget Settings cell + + private var widgetSettingsRow: some View { + Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { + HStack(alignment: .center, spacing: 0) { + BodyMText(t("widgets__widget__settings"), textColor: .textPrimary) + + Spacer() + + BodyMText( + hasCustomOptions + ? t("widgets__widget__edit_custom") + : t("widgets__widget__edit_default"), + textColor: .textSecondary + ) + + Image("chevron") + .resizable() + .foregroundColor(.textSecondary) + .frame(width: 24, height: 24) + .padding(.leading, 5) + } + .frame(maxWidth: .infinity, minHeight: 51) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("WidgetEdit") + } + + // MARK: - Carousel + + private var carousel: some View { + TabView(selection: $carouselPage) { + compactPage.tag(0) + widePage.tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(maxHeight: .infinity) + } + + private var compactPage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = viewModel.blockData { + BlocksWidgetCompactContent(data: data, options: currentOptions) + } else { + placeholderCompact + } + } + .frame(width: 163, height: 192) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private var widePage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = viewModel.blockData { + BlocksWidgetWideContent(data: data, options: currentOptions) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + placeholderWide + } + } + .frame(maxWidth: .infinity) + Spacer(minLength: 0) + } + } + + private var placeholderCompact: some View { + Color.gray6 + .cornerRadius(16) + .overlay(ProgressView()) + } + + private var placeholderWide: some View { + Color.gray6 + .cornerRadius(16) + .frame(height: 180) + .overlay(ProgressView()) + } + + // MARK: - Size label & page indicator + + private var sizeLabel: some View { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__widget__size_small") + : t("widgets__widget__size_wide"), + textColor: .textSecondary + ) + .textCase(.uppercase) + Spacer() + } + } + + private var pageIndicator: some View { + HStack(spacing: 8) { + Spacer() + ForEach(0 ..< 2, id: \.self) { index in + Circle() + .fill(carouselPage == index ? Color.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 2b603f768..91661143f 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: return newsOptions.showTitle || newsOptions.showSource || newsOptions.showDate case .facts: @@ -94,14 +98,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 114bbdf9a..878c6e2cc 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 020bbebf7..e3464efc7 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -55,15 +55,21 @@ struct WidgetEditView: View { editLogic?.resetOptions() } + /// v61 widget configuration screens (Price, News, Blocks) use the widget name as the title + /// and skip the legacy description block. + private var usesV61Header: Bool { + id == .price || id == .blocks + } + var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar( - title: id == .price ? widget.name : t("widgets__widget__edit"), - showMenuButton: id != .price + title: usesV61Header ? widget.name : t("widgets__widget__edit"), + showMenuButton: !usesV61Header ) .padding(.bottom, 16) - if id != .price { + if !usesV61Header { BodyMText( t("widgets__widget__edit_description", variables: ["name": widget.name]), textColor: .textSecondary diff --git a/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/Contents.json b/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/Contents.json new file mode 100644 index 000000000..ae0bb361f --- /dev/null +++ b/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "arrow-up-down.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/arrow-up-down.pdf b/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/arrow-up-down.pdf new file mode 100644 index 000000000..34bc06e51 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/arrow-up-down.imageset/arrow-up-down.pdf differ diff --git a/BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json b/BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json new file mode 100644 index 000000000..cba1e6c79 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/calendar.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "calendar.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/calendar.imageset/calendar.pdf b/BitkitWidget/Assets.xcassets/calendar.imageset/calendar.pdf new file mode 100644 index 000000000..4beb7cb14 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/calendar.imageset/calendar.pdf differ diff --git a/BitkitWidget/Assets.xcassets/clock.imageset/Contents.json b/BitkitWidget/Assets.xcassets/clock.imageset/Contents.json new file mode 100644 index 000000000..0397df2e7 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/clock.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "clock.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/clock.imageset/clock.pdf b/BitkitWidget/Assets.xcassets/clock.imageset/clock.pdf new file mode 100644 index 000000000..bf69f0658 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/clock.imageset/clock.pdf differ diff --git a/BitkitWidget/Assets.xcassets/coins.imageset/Contents.json b/BitkitWidget/Assets.xcassets/coins.imageset/Contents.json new file mode 100644 index 000000000..7ca0a2a25 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/coins.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "coins.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/coins.imageset/coins.pdf b/BitkitWidget/Assets.xcassets/coins.imageset/coins.pdf new file mode 100644 index 000000000..88f4fd818 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/coins.imageset/coins.pdf differ diff --git a/BitkitWidget/Assets.xcassets/cube.imageset/Contents.json b/BitkitWidget/Assets.xcassets/cube.imageset/Contents.json new file mode 100644 index 000000000..9c36c08b7 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/cube.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "cube.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/cube.imageset/cube.pdf b/BitkitWidget/Assets.xcassets/cube.imageset/cube.pdf new file mode 100644 index 000000000..75afbf287 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/cube.imageset/cube.pdf differ diff --git a/BitkitWidget/Assets.xcassets/file-text.imageset/Contents.json b/BitkitWidget/Assets.xcassets/file-text.imageset/Contents.json new file mode 100644 index 000000000..5064d8d33 --- /dev/null +++ b/BitkitWidget/Assets.xcassets/file-text.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "file-text.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/file-text.imageset/file-text.pdf b/BitkitWidget/Assets.xcassets/file-text.imageset/file-text.pdf new file mode 100644 index 000000000..3d68de81a Binary files /dev/null and b/BitkitWidget/Assets.xcassets/file-text.imageset/file-text.pdf differ diff --git a/BitkitWidget/Assets.xcassets/globe.imageset/Contents.json b/BitkitWidget/Assets.xcassets/globe.imageset/Contents.json new file mode 100644 index 000000000..fbc26e51a --- /dev/null +++ b/BitkitWidget/Assets.xcassets/globe.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "globe.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/BitkitWidget/Assets.xcassets/globe.imageset/globe.pdf b/BitkitWidget/Assets.xcassets/globe.imageset/globe.pdf new file mode 100644 index 000000000..08a6eddc1 Binary files /dev/null and b/BitkitWidget/Assets.xcassets/globe.imageset/globe.pdf differ diff --git a/BitkitWidget/BitkitWidget.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..17ab544da --- /dev/null +++ b/BitkitWidget/BlocksHomeScreenWidget.swift @@ -0,0 +1,214 @@ +import SwiftUI +import WidgetKit + +// MARK: - Entry + +struct BlocksWidgetEntry: TimelineEntry { + let date: Date + let block: CachedBlock? + let options: BlocksWidgetOptions + /// True when no fresh data could be fetched and there is nothing in cache to fall back to. + let showsError: Bool +} + +// MARK: - Helpers + +private enum BlocksWidgetEntryBuilder { + static let refreshInterval: TimeInterval = 15 * 60 +} + +// MARK: - Timeline Provider + +struct BlocksWidgetProvider: TimelineProvider { + /// Stable mock for widget gallery / placeholder snapshots. + private static let mockBlock = CachedBlock( + height: "870,123", + time: "01:31:42 UTC", + date: "11/2/2024", + transactionCount: "2,175", + size: "1,606 KB", + fees: "25,059,357" + ) + + private static let mockEntry = BlocksWidgetEntry( + date: Date(), + block: mockBlock, + options: BlocksWidgetOptions(), + showsError: false + ) + + func placeholder(in _: Context) -> BlocksWidgetEntry { + Self.mockEntry + } + + func getSnapshot(in context: Context, completion: @escaping (BlocksWidgetEntry) -> Void) { + let options = BlocksHomeScreenWidgetOptionsStore.load() + + if context.isPreview { + completion(BlocksWidgetEntry( + date: Self.mockEntry.date, + block: Self.mockBlock, + options: options, + showsError: false + )) + return + } + + let cached = BlocksWidgetService.cachedLatest() + completion(BlocksWidgetEntry( + date: Date(), + block: cached, + options: options, + showsError: false + )) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let options = BlocksHomeScreenWidgetOptionsStore.load() + + Task { + let entry: BlocksWidgetEntry + do { + let fresh = try await BlocksWidgetService.fetchFreshLatest() + entry = BlocksWidgetEntry(date: Date(), block: fresh, options: options, showsError: false) + } catch { + if let cached = BlocksWidgetService.cachedLatest() { + entry = BlocksWidgetEntry(date: Date(), block: cached, options: options, showsError: false) + } else { + entry = BlocksWidgetEntry(date: Date(), block: nil, options: options, showsError: true) + } + } + + let nextRefresh = Date().addingTimeInterval(BlocksWidgetEntryBuilder.refreshInterval) + completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + } + } +} + +// MARK: - View + +struct BlocksHomeScreenWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode + + var entry: BlocksWidgetProvider.Entry + + var body: some View { + content + .containerBackground(for: .widget) { backgroundView } + } + + @ViewBuilder + private var content: some View { + if entry.showsError { + errorView + } else if let block = entry.block { + switch widgetFamily { + case .systemSmall: + compactLayout(block: block) + default: + wideLayout(block: block, fields: entry.options.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 (`.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 + 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]) + } +} 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.