Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
184 changes: 89 additions & 95 deletions Bitkit/Components/Widgets/BlocksWidget.swift
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)
}
}

Expand Down
2 changes: 2 additions & 0 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,8 @@ struct MainNavView: View {
PriceWidgetPreviewView()
case .news:
NewsWidgetPreviewView()
case .blocks:
BlocksWidgetPreviewView()
default:
WidgetDetailView(id: widgetType)
}
Expand Down
43 changes: 43 additions & 0 deletions Bitkit/Models/BlocksWidgetData.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
85 changes: 85 additions & 0 deletions Bitkit/Models/BlocksWidgetFields.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading