Skip to content
Open
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
190 changes: 188 additions & 2 deletions Bitkit.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

174 changes: 78 additions & 96 deletions Bitkit/Components/Widgets/BlocksWidget.swift
Original file line number Diff line number Diff line change
@@ -1,34 +1,16 @@
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: - Widget

/// A widget that displays Bitcoin block information
/// 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 +21,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))
}
if options.date {
items.append((key: "date", label: blocksMapping["date"]!, value: data.date))
@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.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.label, 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.enabledFields, 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
116 changes: 77 additions & 39 deletions Bitkit/Components/Widgets/NewsWidget.swift
Original file line number Diff line number Diff line change
@@ -1,27 +1,13 @@
import SwiftUI

/// Options for configuring the NewsWidget
struct NewsWidgetOptions: Codable, Equatable {
var showDate: Bool = true
var showTitle: Bool = true
var showSource: Bool = true
}

/// A widget that displays a news article
/// A widget that displays a news article.
struct NewsWidget: View {
/// Configuration options for the widget
var options: NewsWidgetOptions = .init()

/// Flag indicating if the widget is in editing mode
var isEditing: Bool = false

/// Callback to signal when editing should end
var onEditingEnd: (() -> Void)?

/// View model for handling news data
@StateObject private var viewModel = NewsViewModel.shared

/// Initialize the widget
init(
options: NewsWidgetOptions = NewsWidgetOptions(),
isEditing: Bool = false,
Expand All @@ -38,40 +24,92 @@ struct NewsWidget: View {
isEditing: isEditing,
onEditingEnd: onEditingEnd
) {
VStack(spacing: 0) {
if viewModel.isLoading {
WidgetContentBuilder.loadingView()
} else if viewModel.error != nil {
WidgetContentBuilder.errorView(t("widgets__news__error"))
} else if let data = viewModel.widgetData {
if options.showDate {
BodyMText(data.timeAgo, textColor: .textPrimary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 16)
content
.contentShape(Rectangle())
.onTapGesture {
if !isEditing, let data = viewModel.widgetData, let url = URL(string: data.link) {
UIApplication.shared.open(url)
}
}
}
.task {
viewModel.startUpdates()
}
}

if options.showTitle {
TitleText(data.title)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private var content: some View {
if viewModel.isLoading && viewModel.widgetData == nil {
WidgetContentBuilder.loadingView()
} else if viewModel.error != nil {
WidgetContentBuilder.errorView(t("widgets__news__error"))
} else if let data = viewModel.widgetData {
NewsWidgetWideContent(data: data, options: options)
}
}
}

// MARK: - Wide layout (in-app + 343-wide carousel page)

struct NewsWidgetWideContent: View {
let data: WidgetData
let options: NewsWidgetOptions

var body: some View {
VStack(alignment: .leading, spacing: 16) {
if options.showTitle {
TitleText(data.title)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
}

if options.showSource || options.showDate {
HStack(alignment: .center, spacing: 8) {
if options.showSource {
WidgetContentBuilder.sourceRow(source: data.publisher)
BodySSBText(data.publisher, textColor: .brandAccent)
.lineLimit(1)
}
Spacer(minLength: 0)
if options.showDate {
BodySSBText(data.timeAgo, textColor: .textSecondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}

// MARK: - Compact layout (small carousel preview + 163×192 OS widget)

struct NewsWidgetCompactContent: View {
let data: WidgetData
let options: NewsWidgetOptions

var body: some View {
VStack(alignment: .leading, spacing: 0) {
if options.showTitle {
TitleText(data.title)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
}
.contentShape(Rectangle())
.onTapGesture {
if !isEditing, let data = viewModel.widgetData, let url = URL(string: data.link) {
UIApplication.shared.open(url)

Spacer(minLength: 8)

if options.showDate {
HStack {
Spacer(minLength: 0)
BodySSBText(data.timeAgo, textColor: .textSecondary)
.lineLimit(1)
}
}
}
.onAppear {
viewModel.startUpdates()
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Color.gray6)
.cornerRadius(16)
}
}

Expand Down
Loading
Loading