From 61db06c5d6ec78beda55d872fedfd4234cac9e1b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 07:57:56 -0300 Subject: [PATCH 01/45] feat: port price widgets related screens to figma V61 --- Bitkit/Components/Widgets/PriceWidget.swift | 226 +++++++-------- Bitkit/MainNavView.swift | 7 +- Bitkit/Models/PriceWidgetOptions.swift | 4 +- .../Localization/en.lproj/Localizable.strings | 9 + Bitkit/Utilities/WidgetsBackupConverter.swift | 5 +- .../Widgets/PriceWidgetPreviewView.swift | 263 ++++++++++++++++++ Bitkit/Views/Widgets/WidgetEditItemView.swift | 38 ++- Bitkit/Views/Widgets/WidgetEditLogic.swift | 21 +- Bitkit/Views/Widgets/WidgetEditModels.swift | 83 +++--- Bitkit/Views/Widgets/WidgetEditView.swift | 14 +- BitkitWidget/PriceHomeScreenWidget.swift | 220 +++++++-------- changelog.d/next/price-widget-v61.changed.md | 1 + 12 files changed, 563 insertions(+), 328 deletions(-) create mode 100644 Bitkit/Views/Widgets/PriceWidgetPreviewView.swift create mode 100644 changelog.d/next/price-widget-v61.changed.md diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index 8da348dab..a5935567e 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -1,21 +1,14 @@ import Charts import SwiftUI -/// A widget that displays cryptocurrency price information with chart +/// Displays Bitcoin price for the user's selected trading pair and timeframe (Figma v61). struct PriceWidget: View { - /// Configuration options for the widget var options: PriceWidgetOptions = .init() - - /// Flag indicating if the widget is in editing mode var isEditing: Bool = false - - /// Callback to signal when editing should end var onEditingEnd: (() -> Void)? - /// Price view model singleton @StateObject private var viewModel = PriceViewModel.shared - /// Initialize the widget init( options: PriceWidgetOptions = PriceWidgetOptions(), isEditing: Bool = false, @@ -32,91 +25,121 @@ struct PriceWidget: View { isEditing: isEditing, onEditingEnd: onEditingEnd ) { - VStack(spacing: 0) { - if viewModel.isLoading && filteredPriceData.isEmpty { - WidgetContentBuilder.loadingView() - } else if viewModel.error != nil { - WidgetContentBuilder.errorView(t("widgets__price__error")) - } else { - ForEach(filteredPriceData, id: \.name) { priceData in - PriceRow(data: priceData) - .accessibilityIdentifier("PriceWidgetRow-\(priceData.name)") - } - } - - if let firstPair = filteredPriceData.first { - PriceChart( - values: firstPair.pastValues, - isPositive: firstPair.change.isPositive, - period: options.selectedPeriod.rawValue - ) - .frame(height: 96) - .padding(.top, 8) - } - - if options.showSource { - WidgetContentBuilder.sourceRow(source: "Bitfinex.com") - .accessibilityIdentifier("PriceWidgetSource") - } - } + content } - .onAppear { - fetchPriceData() - } - .onChange(of: options.selectedPairs) { - fetchPriceData() - } - .onChange(of: options.selectedPeriod) { - fetchPriceData() + .onAppear { fetchPriceData() } + .onChange(of: options.selectedPairs) { fetchPriceData() } + .onChange(of: options.selectedPeriod) { fetchPriceData() } + } + + @ViewBuilder + private var content: some View { + if viewModel.isLoading && primaryPrice == nil { + WidgetContentBuilder.loadingView() + } else if viewModel.error != nil { + WidgetContentBuilder.errorView(t("widgets__price__error")) + } else if let primary = primaryPrice { + PriceWidgetWideContent(data: primary, period: options.selectedPeriod) } } - private var filteredPriceData: [PriceData] { + /// Single pair (v61). Falls back to first available data if the selection isn't loaded yet. + private var primaryPrice: PriceData? { let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod) - let dataByPair = Dictionary(uniqueKeysWithValues: currentPeriodData.map { ($0.name, $0) }) - return options.selectedPairs.compactMap { pair in - dataByPair[pair] + if let preferred = options.selectedPairs.first, + let match = currentPeriodData.first(where: { $0.name == preferred }) + { + return match } + return currentPeriodData.first } - /// Fetch price data from view model private func fetchPriceData() { viewModel.fetchPriceData(pairs: options.selectedPairs, period: options.selectedPeriod) } } -// MARK: - Price Row Component +// MARK: - Wide layout (in-app + carousel page) + +struct PriceWidgetWideContent: View { + let data: PriceData + let period: GraphPeriod + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 16) { + CaptionMText("\(data.name) \(period.rawValue)", textColor: .textSecondary) + .textCase(.uppercase) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(data.change.formatted) + .font(Fonts.bold(size: 22)) + .foregroundColor(data.change.isPositive ? .greenAccent : .redAccent) + .lineLimit(1) + } + + Text(data.price) + .font(Fonts.bold(size: 34)) + .foregroundColor(.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(maxWidth: .infinity, alignment: .leading) + } + + PriceChart(values: data.pastValues, isPositive: data.change.isPositive) + .frame(height: 48) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Compact layout (small carousel preview only) -struct PriceRow: View { +struct PriceWidgetCompactContent: View { let data: PriceData + let period: GraphPeriod var body: some View { - HStack { - BodySSBText(data.name, textColor: .textSecondary) + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 0) { + CaptionMText(data.name, textColor: .textSecondary) + .textCase(.uppercase) + Spacer(minLength: 0) + CaptionMText(period.rawValue, textColor: .textSecondary) + .textCase(.uppercase) + } + + Text(data.price) + .font(Fonts.bold(size: 22)) + .foregroundColor(.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) - Spacer() + Text(data.change.formatted) + .font(Fonts.semiBold(size: 15)) + .foregroundColor(data.change.isPositive ? .greenAccent : .redAccent) + .lineLimit(1) + } - BodySSBText(data.change.formatted, textColor: data.change.isPositive ? .greenAccent : .redAccent) - .padding(.trailing, 8) - BodySSBText(data.price, textColor: .textPrimary) + PriceChart(values: data.pastValues, isPositive: data.change.isPositive) + .frame(height: 64) } - .frame(minHeight: 28) + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.gray6) + .cornerRadius(16) } } -// MARK: - Price Chart Component +// MARK: - Chart (line-only per Figma v61) struct PriceChart: View { let values: [Double] let isPositive: Bool - let period: String - // Chart styling constants private let lineWidth: CGFloat = 1.3 - private let chartPadding: CGFloat = 4 - private let cornerRadius: CGFloat = 8 - private let gradientOpacityTop: CGFloat = 0.64 - private let gradientOpacityBottom: CGFloat = 0.08 private var normalizedValues: [Double] { guard values.count > 1 else { return values } @@ -127,76 +150,31 @@ struct PriceChart: View { guard range > 0 else { return values.map { _ in 0.5 } } - // Map to 0.15...0.85 range for more generous margins - // This prevents chart content from reaching the very edges where clipping occurs return values.map { value in let normalized = (value - minValue) / range - return 0.15 + (normalized * 0.7) // Maps 0-1 to 0.15-0.85 + return 0.15 + (normalized * 0.7) } } - private var chartColors: (gradient: [Color], line: Color) { - if isPositive { - return ( - gradient: [.greenAccent.opacity(gradientOpacityTop), .greenAccent.opacity(gradientOpacityBottom)], - line: .greenAccent - ) - } else { - return ( - gradient: [.redAccent.opacity(gradientOpacityTop), .redAccent.opacity(gradientOpacityBottom)], - line: .redAccent - ) - } + private var lineColor: Color { + isPositive ? .greenAccent : .redAccent } var body: some View { - ZStack(alignment: .bottomLeading) { - Chart { - ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in - // Area fill with gradient - AreaMark( - x: .value("Index", index), - y: .value("Price", value) - ) - .foregroundStyle( - LinearGradient( - colors: chartColors.gradient, - startPoint: .top, - endPoint: .bottom - ) - ) - .interpolationMethod(.catmullRom) - - // Line on top - LineMark( - x: .value("Index", index), - y: .value("Price", value) - ) - .foregroundStyle(chartColors.line) - .lineStyle(StrokeStyle(lineWidth: lineWidth)) - .interpolationMethod(.catmullRom) - } - } - .chartXAxis(.hidden) - .chartYAxis(.hidden) - // Y scale domain provides buffer zone beyond data range (0.15...0.85) - // This ensures chart elements (lines, curves) don't get clipped at edges - .chartYScale(domain: 0.1 ... 0.9) // Domain slightly larger than data range for extra buffer - // Apply rounded corners only to bottom - chart content extends to edges for visible clipping - // The internal margins above prevent any actual data from being cut off - .clipShape( - .rect( - topLeadingRadius: 0, - bottomLeadingRadius: cornerRadius, - bottomTrailingRadius: cornerRadius, - topTrailingRadius: 0 + Chart { + ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in + LineMark( + x: .value("Index", index), + y: .value("Price", value) ) - ) - - // Period label - CaptionBText(period, textColor: isPositive ? .green50 : .red50) - .padding(7) + .foregroundStyle(lineColor) + .lineStyle(StrokeStyle(lineWidth: lineWidth)) + .interpolationMethod(.catmullRom) + } } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartYScale(domain: 0.1 ... 0.9) } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 1eae66d3d..56858982d 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -430,7 +430,12 @@ struct MainNavView: View { // Widgets case .widgetsIntro: WidgetsIntroView() case .widgetsList: WidgetsListView() - case let .widgetDetail(widgetType): WidgetDetailView(id: widgetType) + case let .widgetDetail(widgetType): + if widgetType == .price { + PriceWidgetPreviewView() + } else { + WidgetDetailView(id: widgetType) + } case let .widgetEdit(widgetType): WidgetEditView(id: widgetType) // Settings diff --git a/Bitkit/Models/PriceWidgetOptions.swift b/Bitkit/Models/PriceWidgetOptions.swift index 94310d05f..987d838f7 100644 --- a/Bitkit/Models/PriceWidgetOptions.swift +++ b/Bitkit/Models/PriceWidgetOptions.swift @@ -1,8 +1,10 @@ import Foundation /// Options for configuring the in-app and home-screen price widgets (shared via App Group for the extension). +/// +/// `selectedPairs` is kept as an array for storage backwards-compatibility with v60. The v61 UI is +/// single-select and only ever reads/writes `[firstPair]`. struct PriceWidgetOptions: Codable, Equatable { var selectedPairs: [String] = ["BTC/USD"] var selectedPeriod: GraphPeriod = .oneDay - var showSource: Bool = false } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 766d3dfda..1dd4673ca 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1387,6 +1387,15 @@ "widgets__price__name" = "Bitcoin Price"; "widgets__price__description" = "Check the latest Bitcoin exchange rates for a variety of fiat currencies."; "widgets__price__error" = "Couldn\'t get price data"; +"widgets__price__currency" = "Currency"; +"widgets__price__timeframe" = "Timeframe"; +"widgets__price__period_day" = "Day"; +"widgets__price__period_week" = "Week"; +"widgets__price__period_month" = "Month"; +"widgets__price__period_year" = "Year"; +"widgets__price__size_small" = "Small"; +"widgets__price__size_wide" = "Wide"; +"widgets__price__widget_settings" = "Widget Settings"; "widgets__news__name" = "Bitcoin Headlines"; "widgets__news__description" = "Read the latest & greatest Bitcoin headlines from various news sites."; "widgets__news__error" = "Couldn\'t get the latest news"; diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 889a76095..f1ba83a71 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -66,7 +66,6 @@ enum WidgetsBackupConverter { pricePreferences = [ "enabledPairs": androidPairs.isEmpty ? ["BTC_USD"] : androidPairs, "period": androidPeriod, - "showSource": options.showSource, ] } case .calculator, .suggestions: @@ -179,8 +178,7 @@ enum WidgetsBackupConverter { let period = convertAndroidPeriodToIos(prefs["period"] as? String) let iosOptions = PriceWidgetOptions( selectedPairs: selectedPairs, - selectedPeriod: period, - showSource: prefs["showSource"] as? Bool ?? false + selectedPeriod: period ) optionsData = try? JSONEncoder().encode(iosOptions) } @@ -243,7 +241,6 @@ enum WidgetsBackupConverter { return [ "enabledPairs": androidPairs.isEmpty ? ["BTC_USD"] : androidPairs, "period": androidPeriod, - "showSource": defaults.showSource, ] } diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift new file mode 100644 index 000000000..4d4eeaf81 --- /dev/null +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -0,0 +1,263 @@ +import SwiftUI + +/// Preview screen for the Bitcoin Price widget (Figma v61). +/// +/// Replaces the generic `WidgetDetailView` for `.price` only — the other widgets continue to use +/// `WidgetDetailView`. Layout differences from the generic preview: centered top-bar title, +/// description, "Widget Settings" cell, and a Compact ↔ Wide carousel. +struct PriceWidgetPreviewView: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + + @StateObject private var viewModel = PriceViewModel.shared + + @State private var carouselPage: Int = 0 + @State private var showDeleteAlert = false + + private let widgetType: WidgetType = .price + + private var widgetName: String { + t("widgets__price__name") + } + + private var widgetDescription: String { + t("widgets__price__description") + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(widgetType) + } + + private var hasCustomOptions: Bool { + widgets.hasCustomOptions(for: widgetType) + } + + private var currentOptions: PriceWidgetOptions { + widgets.getOptions(for: widgetType, as: PriceWidgetOptions.self) + } + + private var primaryPrice: PriceData? { + let options = currentOptions + let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod) + if let preferred = options.selectedPairs.first, + let match = currentPeriodData.first(where: { $0.name == preferred }) + { + return match + } + return currentPeriodData.first + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: widgetName) + .padding(.bottom, 16) + + BodyMText(widgetDescription, textColor: .textSecondary) + .padding(.bottom, 16) + + Divider().background(Color.white.opacity(0.1)) + + widgetSettingsRow + + Divider().background(Color.white.opacity(0.1)) + + Spacer(minLength: 0) + + carousel + + sizeLabel + .padding(.top, 8) + + pageIndicator + .padding(.top, 8) + + Spacer(minLength: 0) + + buttonsRow + .padding(.top, 16) + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .onAppear { + let options = currentOptions + viewModel.fetchPriceData(pairs: options.selectedPairs, period: options.selectedPeriod) + } + .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__price__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) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .padding(.vertical, 14) + .accessibilityIdentifier("WidgetEdit") + } + + // MARK: - Carousel + + private var carousel: some View { + TabView(selection: $carouselPage) { + compactPage + .tag(0) + + widePage + .tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(height: 240) + } + + private var compactPage: some View { + HStack { + Spacer() + Group { + if let data = primaryPrice { + PriceWidgetCompactContent(data: data, period: currentOptions.selectedPeriod) + } else { + placeholderCompact + } + } + .frame(width: 163, height: 192) + Spacer() + } + } + + private var widePage: some View { + HStack { + Spacer() + Group { + if let data = primaryPrice { + PriceWidgetWideContent(data: data, period: currentOptions.selectedPeriod) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + placeholderWide + } + } + .frame(maxWidth: .infinity) + Spacer() + } + } + + private var placeholderCompact: some View { + Color.gray6 + .cornerRadius(16) + .overlay(ProgressView()) + } + + private var placeholderWide: some View { + Color.gray6 + .cornerRadius(16) + .frame(height: 130) + .overlay(ProgressView()) + } + + // MARK: - Size label & page indicator + + private var sizeLabel: some View { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__price__size_small") + : t("widgets__price__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("common__save"), + 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 { + PriceWidgetPreviewView() + .environmentObject(NavigationViewModel()) + .environmentObject(WidgetsViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Widgets/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index 423477dbe..572044c4b 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -5,7 +5,24 @@ struct WidgetEditItemView: View { let onToggle: () -> Void var body: some View { - let content = VStack(spacing: 0) { + switch item.type { + case .sectionHeader: + item.titleView + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) + .padding(.bottom, 16) + case .staticItem: + row + case .toggleItem: + Button(action: onToggle) { + row + } + .buttonStyle(PlainButtonStyle()) + } + } + + private var row: some View { + VStack(spacing: 0) { HStack(spacing: 16) { item.titleView .frame(maxWidth: .infinity, alignment: .leading) @@ -17,24 +34,17 @@ struct WidgetEditItemView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - Image("check-mark") - .resizable() - .foregroundColor(item.isChecked ? .brandAccent : .gray3) - .frame(width: 32, height: 32) + if item.type != .staticItem { + Image("check-mark") + .resizable() + .foregroundColor(item.isChecked ? .brandAccent : .gray3) + .frame(width: 32, height: 32) + } } .padding(.vertical, 16) .contentShape(Rectangle()) Divider() } - - if item.type == .staticItem { - content - } else { - Button(action: onToggle) { - content - } - .buttonStyle(PlainButtonStyle()) - } } } diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index 3820ab6f0..a991264c1 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -138,14 +138,8 @@ class WidgetEditLogic: ObservableObject { } case .price: switch item.key { - case "BTC/USD": - toggleTradingPair("BTC/USD") - case "BTC/EUR": - toggleTradingPair("BTC/EUR") - case "BTC/GBP": - toggleTradingPair("BTC/GBP") - case "BTC/JPY": - toggleTradingPair("BTC/JPY") + case "BTC/USD", "BTC/EUR", "BTC/GBP", "BTC/JPY": + selectTradingPair(item.key) case "1D": priceOptions.selectedPeriod = .oneDay case "1W": @@ -154,8 +148,6 @@ class WidgetEditLogic: ObservableObject { priceOptions.selectedPeriod = .oneMonth case "1Y": priceOptions.selectedPeriod = .oneYear - case "showSource": - priceOptions.showSource.toggle() default: break } @@ -165,12 +157,9 @@ class WidgetEditLogic: ObservableObject { onStateChange?() } - private func toggleTradingPair(_ pairName: String) { - if priceOptions.selectedPairs.contains(pairName) { - priceOptions.selectedPairs.removeAll { $0 == pairName } - } else { - priceOptions.selectedPairs.append(pairName) - } + /// Single-select per Figma v61 — replaces the array with a single pair. + private func selectTradingPair(_ pairName: String) { + priceOptions.selectedPairs = [pairName] } func loadCurrentOptions() { diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 575f500b6..f4f75989e 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -1,10 +1,27 @@ import SwiftUI +// MARK: - GraphPeriod display + +extension GraphPeriod { + /// Full-word label shown in the Price edit screen (Day / Week / Month / Year). + /// The widget itself uses `rawValue` ("1D"/...) per Figma v61. + var editScreenLabel: String { + switch self { + case .oneDay: return t("widgets__price__period_day") + case .oneWeek: return t("widgets__price__period_week") + case .oneMonth: return t("widgets__price__period_month") + case .oneYear: return t("widgets__price__period_year") + } + } +} + // MARK: - Widget Edit Item Models enum WidgetItemType { case toggleItem case staticItem + /// Non-tappable section header (uppercase caption above a group of items). + case sectionHeader } struct WidgetEditItem { @@ -357,68 +374,62 @@ enum WidgetEditItemFactory { } @MainActor - static func getPriceItems(priceOptions: PriceWidgetOptions, priceDataByPeriod: [GraphPeriod: [PriceData]] = [:]) -> [WidgetEditItem] { + static func getPriceItems(priceOptions: PriceWidgetOptions, priceDataByPeriod _: [GraphPeriod: [PriceData]] = [:]) -> [WidgetEditItem] { var items: [WidgetEditItem] = [] - // Trading pair options with live or fallback prices - let fallbackPrices = ["$ 43,250", "€ 39,850", "£ 34,120", "¥ 6,245,000"] - - // Use current period data for trading pair prices - let currentPeriodData = priceDataByPeriod[priceOptions.selectedPeriod] ?? [] - - for (index, pair) in tradingPairNames.enumerated() { - // Try to find live data for this pair - let livePrice = currentPeriodData.first { $0.name == pair }?.price ?? fallbackPrices[index] + // CURRENCY section (single-select) + items.append(sectionHeaderItem(key: "currency_header", title: t("widgets__price__currency"))) + let selectedPair = priceOptions.selectedPairs.first + for pair in tradingPairNames { + let isSelected = selectedPair == pair items.append( WidgetEditItem( key: pair, type: .toggleItem, - title: pair, - value: livePrice, - isChecked: priceOptions.selectedPairs.contains(pair) + titleView: AnyView( + BodySSBText(pair, textColor: isSelected ? .textPrimary : .textSecondary) + ), + valueView: nil, + isChecked: isSelected ) ) } - // Period selection (radio group) with charts - let periods: [GraphPeriod] = [.oneDay, .oneWeek, .oneMonth, .oneYear] - - for period in periods { - // Get data for this specific period - let periodData = priceDataByPeriod[period] ?? [] - let firstPairData = periodData.first + // TIMEFRAME section (single-select). Full-word labels per Figma v61. + items.append(sectionHeaderItem(key: "timeframe_header", title: t("widgets__price__timeframe"))) + for period in GraphPeriod.allCases { + let isSelected = priceOptions.selectedPeriod == period items.append( WidgetEditItem( key: period.rawValue, type: .toggleItem, titleView: AnyView( - PriceChart( - values: firstPairData?.pastValues ?? [], - isPositive: firstPairData?.change.isPositive ?? true, - period: period.rawValue - ) + BodySSBText(period.editScreenLabel, textColor: isSelected ? .textPrimary : .textSecondary) ), valueView: nil, - isChecked: priceOptions.selectedPeriod == period + isChecked: isSelected ) ) } - items.append( - WidgetEditItem( - key: "showSource", - type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("Bitfinex.com", textColor: .textSecondary)), - isChecked: priceOptions.showSource - ) - ) - return items } + private static func sectionHeaderItem(key: String, title: String) -> WidgetEditItem { + WidgetEditItem( + key: key, + type: .sectionHeader, + titleView: AnyView( + CaptionMText(title, textColor: .textSecondary) + .textCase(.uppercase) + ), + valueView: nil, + isChecked: false + ) + } + @MainActor static func getWeatherItems( weatherViewModel: WeatherViewModel, diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index 8cfc81174..d96a89d17 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -57,14 +57,16 @@ struct WidgetEditView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("widgets__widget__edit")) + NavigationBar(title: id == .price ? widget.name : t("widgets__widget__edit")) .padding(.bottom, 16) - BodyMText( - t("widgets__widget__edit_description", variables: ["name": widget.name]), - textColor: .textSecondary - ) - .padding(.bottom, 16) + if id != .price { + BodyMText( + t("widgets__widget__edit_description", variables: ["name": widget.name]), + textColor: .textSecondary + ) + .padding(.bottom, 16) + } ScrollView(showsIndicators: false) { LazyVStack(spacing: 0) { diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index e1a44af7e..ae3c22e1b 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -86,123 +86,126 @@ struct PriceHomeScreenWidgetEntryView: View { var entry: PriceWidgetProvider.Entry var body: some View { - VStack(alignment: .leading, spacing: 8) { - content - if entry.options.showSource, !entry.prices.isEmpty { - HStack { - Spacer() - CaptionBText("Bitfinex.com", textColor: secondaryTextColor) - } - } - } - .containerBackground(for: .widget) { backgroundView } + content + .containerBackground(for: .widget) { backgroundView } } @ViewBuilder private var content: some View { if entry.showsError { errorView - } else if entry.prices.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - } else { + } else if let primary = primaryPrice { switch widgetFamily { case .systemSmall: - smallContent + compactLayout(data: primary) default: - rowsAndChart + wideLayout(data: primary) } + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + + /// Always render the first selected pair (v61 is single-pair). + private var primaryPrice: PriceData? { + let preferred = entry.options.selectedPairs.first + if let preferred, let match = entry.prices.first(where: { $0.name == preferred }) { + return match } + return entry.prices.first } - // MARK: - Variants + // MARK: - Compact (small widget — 163×192) - private var smallContent: some View { - let primary = entry.prices.first - return VStack(alignment: .leading, spacing: 4) { - BodySSBText(primary?.name ?? "BTC/USD", textColor: secondaryTextColor) - .lineLimit(1) + private func compactLayout(data: PriceData) -> some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 0) { + captionUpText(data.name) + Spacer(minLength: 0) + captionUpText(entry.options.selectedPeriod.rawValue) + } - Text(primary?.price ?? "—") - .font(Fonts.bold(size: 22)) - .foregroundColor(valueTextColor) - .lineLimit(1) - .minimumScaleFactor(0.7) - .widgetAccentable() + priceText(data.price, size: 22, lineHeight: 26) - if let change = primary?.change { - BodySSBText(change.formatted, textColor: changeColor(isPositive: change.isPositive)) + Text(data.change.formatted) + .font(Fonts.semiBold(size: 15)) + .foregroundColor(changeColor(isPositive: data.change.isPositive)) .lineLimit(1) .widgetAccentable() } - Spacer(minLength: 0) + chart(values: data.pastValues, isPositive: data.change.isPositive, height: 64) } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private var rowsAndChart: some View { - VStack(spacing: 0) { - ForEach(visibleRows, id: \.name) { data in - priceRow(data: data) - } + // MARK: - Wide (medium / large widget) - if let firstPair = entry.prices.first { - PriceWidgetChart( - values: firstPair.pastValues, - isPositive: firstPair.change.isPositive, - period: entry.options.selectedPeriod.rawValue, - renderingMode: widgetRenderingMode - ) - .frame(height: chartHeight) - .padding(.top, 8) + private func wideLayout(data: PriceData) -> some View { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 16) { + captionUpText("\(data.name) \(entry.options.selectedPeriod.rawValue)") + .frame(maxWidth: .infinity, alignment: .leading) + + Text(data.change.formatted) + .font(Fonts.bold(size: 22)) + .foregroundColor(changeColor(isPositive: data.change.isPositive)) + .lineLimit(1) + .widgetAccentable() + } + + priceText(data.price, size: 34, lineHeight: 34) } - } - } - private var visibleRows: [PriceData] { - switch widgetFamily { - case .systemSmall: Array(entry.prices.prefix(1)) - case .systemMedium: Array(entry.prices.prefix(2)) - case .systemLarge, .systemExtraLarge: Array(entry.prices.prefix(4)) - default: Array(entry.prices.prefix(1)) + chart(values: data.pastValues, isPositive: data.change.isPositive, height: chartHeight) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private var chartHeight: CGFloat { switch widgetFamily { - case .systemMedium: 64 - case .systemLarge, .systemExtraLarge: 120 - default: 96 + case .systemLarge, .systemExtraLarge: return 120 + default: return 48 } } - private var errorView: some View { - Text("Couldn’t load price.") - .font(Fonts.medium(size: 14)) + // MARK: - Sub-views + + private func captionUpText(_ text: String) -> Text { + Text(text) + .font(Fonts.medium(size: 13)) + .tracking(1) .foregroundColor(secondaryTextColor) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - // MARK: - Row - - private func priceRow(data: PriceData) -> some View { - HStack(spacing: 0) { - BodySSBText(data.name, textColor: secondaryTextColor) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) + private func priceText(_ value: String, size: CGFloat, lineHeight: CGFloat) -> some View { + Text(value) + .font(Fonts.bold(size: size)) + .foregroundColor(valueTextColor) + .lineLimit(1) + .minimumScaleFactor(0.7) + .widgetAccentable() + } - BodySSBText(data.change.formatted, textColor: changeColor(isPositive: data.change.isPositive)) - .padding(.trailing, 8) - .lineLimit(1) - .widgetAccentable() + private func chart(values: [Double], isPositive: Bool, height: CGFloat) -> some View { + PriceWidgetChart( + values: values, + isPositive: isPositive, + renderingMode: widgetRenderingMode + ) + .frame(height: height) + .widgetAccentable() + } - BodySSBText(data.price, textColor: valueTextColor) - .lineLimit(1) - .minimumScaleFactor(0.75) - .widgetAccentable() - } - .frame(minHeight: 24) + private var errorView: some View { + // Hardcoded — widget extension target does not bundle the app's localization helpers. + Text("Couldn’t load price.") + .font(Fonts.medium(size: 14)) + .foregroundColor(secondaryTextColor) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } // MARK: - Colors @@ -225,12 +228,11 @@ struct PriceHomeScreenWidgetEntryView: View { } } -// MARK: - Chart +// MARK: - Chart (line-only per Figma v61) private struct PriceWidgetChart: View { let values: [Double] let isPositive: Bool - let period: String let renderingMode: WidgetRenderingMode private var normalizedValues: [Double] { @@ -247,55 +249,21 @@ private struct PriceWidgetChart: View { return isPositive ? .greenAccent : .redAccent } - private var gradientColors: [Color] { - guard renderingMode == .fullColor else { return [.primary.opacity(0.3), .clear] } - let base: Color = isPositive ? .greenAccent : .redAccent - return [base.opacity(0.64), base.opacity(0.08)] - } - - private var labelColor: Color { - guard renderingMode == .fullColor else { return .secondary } - return isPositive ? .green50 : .red50 - } - var body: some View { - ZStack(alignment: .bottomLeading) { - Chart { - ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in - AreaMark( - x: .value("Index", index), - y: .value("Price", value) - ) - .foregroundStyle( - LinearGradient(colors: gradientColors, startPoint: .top, endPoint: .bottom) - ) - .interpolationMethod(.catmullRom) - - LineMark( - x: .value("Index", index), - y: .value("Price", value) - ) - .foregroundStyle(lineColor) - .lineStyle(StrokeStyle(lineWidth: 1.3)) - .interpolationMethod(.catmullRom) - } - } - .chartXAxis(.hidden) - .chartYAxis(.hidden) - .chartYScale(domain: 0.1 ... 0.9) - .clipShape( - .rect( - topLeadingRadius: 0, - bottomLeadingRadius: 8, - bottomTrailingRadius: 8, - topTrailingRadius: 0 + Chart { + ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in + LineMark( + x: .value("Index", index), + y: .value("Price", value) ) - ) - .widgetAccentable() - - CaptionBText(period, textColor: labelColor) - .padding(7) + .foregroundStyle(lineColor) + .lineStyle(StrokeStyle(lineWidth: 1.3)) + .interpolationMethod(.catmullRom) + } } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartYScale(domain: 0.1 ... 0.9) } } diff --git a/changelog.d/next/price-widget-v61.changed.md b/changelog.d/next/price-widget-v61.changed.md new file mode 100644 index 000000000..d6bc025ee --- /dev/null +++ b/changelog.d/next/price-widget-v61.changed.md @@ -0,0 +1 @@ +Redesign the Bitcoin Price widget (in-app and home screen) to match Figma v61: single-currency selection, dedicated wide/compact layouts, line-only chart, and an updated edit and preview flow. From 28a550f2e4e30060eaed4a9f22b51a82cf46ec88 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 08:07:53 -0300 Subject: [PATCH 02/45] fix: spacing and alignment --- .../Localization/en.lproj/Localizable.strings | 1 + .../Widgets/PriceWidgetPreviewView.swift | 55 ++++++++++--------- Bitkit/Views/Widgets/WidgetEditItemView.swift | 3 +- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 1dd4673ca..968bc586c 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1396,6 +1396,7 @@ "widgets__price__size_small" = "Small"; "widgets__price__size_wide" = "Wide"; "widgets__price__widget_settings" = "Widget Settings"; +"widgets__widget__save_widget" = "Save Widget"; "widgets__news__name" = "Bitcoin Headlines"; "widgets__news__description" = "Read the latest & greatest Bitcoin headlines from various news sites."; "widgets__news__error" = "Couldn\'t get the latest news"; diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index 4d4eeaf81..48652a637 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -48,33 +48,36 @@ struct PriceWidgetPreviewView: View { } var body: some View { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 16) { NavigationBar(title: widgetName) - .padding(.bottom, 16) - BodyMText(widgetDescription, textColor: .textSecondary) - .padding(.bottom, 16) + // Content (description + Widget Settings cell with surrounding dividers) + VStack(alignment: .leading, spacing: 0) { + BodyMText(widgetDescription, textColor: .textSecondary) + .padding(.bottom, 16) - Divider().background(Color.white.opacity(0.1)) + Divider().background(Color.white.opacity(0.1)) - widgetSettingsRow + widgetSettingsRow - Divider().background(Color.white.opacity(0.1)) + Divider().background(Color.white.opacity(0.1)) + } - Spacer(minLength: 0) + // Carousel section (centered widget + size label + page indicator) + VStack(spacing: 16) { + Spacer(minLength: 0) - carousel + carousel - sizeLabel - .padding(.top, 8) + Spacer(minLength: 0) - pageIndicator - .padding(.top, 8) + sizeLabel - Spacer(minLength: 0) + pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) buttonsRow - .padding(.top, 16) } .navigationBarHidden(true) .padding(.horizontal, 16) @@ -117,11 +120,10 @@ struct PriceWidgetPreviewView: View { .frame(width: 24, height: 24) .padding(.leading, 5) } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, minHeight: 51) .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) - .padding(.vertical, 14) .accessibilityIdentifier("WidgetEdit") } @@ -136,12 +138,12 @@ struct PriceWidgetPreviewView: View { .tag(1) } .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(height: 240) + .frame(height: 320) } private var compactPage: some View { - HStack { - Spacer() + VStack { + Spacer(minLength: 0) Group { if let data = primaryPrice { PriceWidgetCompactContent(data: data, period: currentOptions.selectedPeriod) @@ -150,13 +152,14 @@ struct PriceWidgetPreviewView: View { } } .frame(width: 163, height: 192) - Spacer() + Spacer(minLength: 0) } + .frame(maxWidth: .infinity) } private var widePage: some View { - HStack { - Spacer() + VStack { + Spacer(minLength: 0) Group { if let data = primaryPrice { PriceWidgetWideContent(data: data, period: currentOptions.selectedPeriod) @@ -168,7 +171,7 @@ struct PriceWidgetPreviewView: View { } } .frame(maxWidth: .infinity) - Spacer() + Spacer(minLength: 0) } } @@ -181,7 +184,7 @@ struct PriceWidgetPreviewView: View { private var placeholderWide: some View { Color.gray6 .cornerRadius(16) - .frame(height: 130) + .frame(height: 152) .overlay(ProgressView()) } @@ -230,7 +233,7 @@ struct PriceWidgetPreviewView: View { } CustomButton( - title: t("common__save"), + title: t("widgets__widget__save_widget"), variant: .primary, size: .large, shouldExpand: true, diff --git a/Bitkit/Views/Widgets/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index 572044c4b..4b4b8bbb7 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -9,8 +9,7 @@ struct WidgetEditItemView: View { case .sectionHeader: item.titleView .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 8) - .padding(.bottom, 16) + .padding(.vertical, 16) case .staticItem: row case .toggleItem: From 10be29edab7379dd4ddabf4f677c437e4e138e7e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 08:09:17 -0300 Subject: [PATCH 03/45] feat: hide menu button from nabigation bar --- Bitkit/Views/Widgets/PriceWidgetPreviewView.swift | 2 +- Bitkit/Views/Widgets/WidgetEditView.swift | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index 48652a637..b8a341c60 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -49,7 +49,7 @@ struct PriceWidgetPreviewView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - NavigationBar(title: widgetName) + NavigationBar(title: widgetName, showMenuButton: false) // Content (description + Widget Settings cell with surrounding dividers) VStack(alignment: .leading, spacing: 0) { diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index d96a89d17..e119ee310 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -57,8 +57,11 @@ struct WidgetEditView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: id == .price ? widget.name : t("widgets__widget__edit")) - .padding(.bottom, 16) + NavigationBar( + title: id == .price ? widget.name : t("widgets__widget__edit"), + showMenuButton: id != .price + ) + .padding(.bottom, 16) if id != .price { BodyMText( From 90680fed51b735002546101d84358a311ca6b467 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 08:15:20 -0300 Subject: [PATCH 04/45] fix: padding --- Bitkit/Views/Widgets/WidgetEditModels.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index f4f75989e..fa1db48ca 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -396,8 +396,7 @@ enum WidgetEditItemFactory { ) } - // TIMEFRAME section (single-select). Full-word labels per Figma v61. - items.append(sectionHeaderItem(key: "timeframe_header", title: t("widgets__price__timeframe"))) + items.append(sectionHeaderItem(key: "timeframe_header", title: t("widgets__price__timeframe"), topInset: 16)) for period in GraphPeriod.allCases { let isSelected = priceOptions.selectedPeriod == period @@ -417,13 +416,14 @@ enum WidgetEditItemFactory { return items } - private static func sectionHeaderItem(key: String, title: String) -> WidgetEditItem { + private static func sectionHeaderItem(key: String, title: String, topInset: CGFloat = 0) -> WidgetEditItem { WidgetEditItem( key: key, type: .sectionHeader, titleView: AnyView( CaptionMText(title, textColor: .textSecondary) .textCase(.uppercase) + .padding(.top, topInset) ), valueView: nil, isChecked: false From 4dfd9a80d0cb134463b0f39afffe19313adc907a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 08:24:56 -0300 Subject: [PATCH 05/45] fix: remove systemLarge widget option --- BitkitWidget/PriceHomeScreenWidget.swift | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index ae3c22e1b..ed6fd31d4 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -160,18 +160,11 @@ struct PriceHomeScreenWidgetEntryView: View { priceText(data.price, size: 34, lineHeight: 34) } - chart(values: data.pastValues, isPositive: data.change.isPositive, height: chartHeight) + chart(values: data.pastValues, isPositive: data.change.isPositive, height: 48) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private var chartHeight: CGFloat { - switch widgetFamily { - case .systemLarge, .systemExtraLarge: return 120 - default: return 48 - } - } - // MARK: - Sub-views private func captionUpText(_ text: String) -> Text { @@ -279,6 +272,6 @@ struct BitkitPriceWidget: Widget { } .configurationDisplayName("Bitcoin Price") .description("Latest Bitcoin price and chart, mirroring the in-app price widget.") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .supportedFamilies([.systemSmall, .systemMedium]) } } From 15dc374167acb4e390272a0d2d9af67de73b4354 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 08:47:25 -0300 Subject: [PATCH 06/45] fix: collect results in input order instead of completion order --- BitkitWidget/PriceWidgetService.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/BitkitWidget/PriceWidgetService.swift b/BitkitWidget/PriceWidgetService.swift index 0574bdf22..c07ba499f 100644 --- a/BitkitWidget/PriceWidgetService.swift +++ b/BitkitWidget/PriceWidgetService.swift @@ -22,16 +22,19 @@ enum PriceWidgetService { // MARK: - Fresh Fetch static func fetchFreshPrices(pairs: [String], period: GraphPeriod) async throws -> [PriceData] { - let results = await withTaskGroup(of: PriceData?.self) { group -> [PriceData] in - for pair in pairs { - group.addTask { try? await fetchPair(pairName: pair, period: period) } + let results = await withTaskGroup(of: (Int, PriceData?).self) { group -> [PriceData] in + for (index, pair) in pairs.enumerated() { + group.addTask { + let data = try? await fetchPair(pairName: pair, period: period) + return (index, data) + } } - var collected: [PriceData] = [] - for await result in group { - if let result { collected.append(result) } + var collected: [(Int, PriceData)] = [] + for await (index, result) in group { + if let result { collected.append((index, result)) } } - return collected + return collected.sorted { $0.0 < $1.0 }.map(\.1) } guard !results.isEmpty else { throw FetchError.noPriceDataAvailable } From 600b422f1ffa05768bf76ce7b86bec28c7011d0a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:09:46 -0300 Subject: [PATCH 07/45] fix: pr comments --- changelog.d/next/{price-widget-v61.changed.md => 542.changed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{price-widget-v61.changed.md => 542.changed.md} (100%) diff --git a/changelog.d/next/price-widget-v61.changed.md b/changelog.d/next/542.changed.md similarity index 100% rename from changelog.d/next/price-widget-v61.changed.md rename to changelog.d/next/542.changed.md From 027da4129d5622213361d5ebba84a2b433eafd0a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:09:51 -0300 Subject: [PATCH 08/45] fix: pr comments --- Bitkit/Components/Widgets/PriceWidget.swift | 18 ++++++++++-------- .../Views/Widgets/PriceWidgetPreviewView.swift | 2 +- Bitkit/Views/Widgets/WidgetEditItemView.swift | 4 ++-- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index a5935567e..c192f4e9c 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -73,10 +73,11 @@ struct PriceWidgetWideContent: View { .textCase(.uppercase) .frame(maxWidth: .infinity, alignment: .leading) - Text(data.change.formatted) - .font(Fonts.bold(size: 22)) - .foregroundColor(data.change.isPositive ? .greenAccent : .redAccent) - .lineLimit(1) + TitleText( + data.change.formatted, + textColor: data.change.isPositive ? .greenAccent : .redAccent + ) + .lineLimit(1) } Text(data.price) @@ -117,10 +118,11 @@ struct PriceWidgetCompactContent: View { .lineLimit(1) .minimumScaleFactor(0.7) - Text(data.change.formatted) - .font(Fonts.semiBold(size: 15)) - .foregroundColor(data.change.isPositive ? .greenAccent : .redAccent) - .lineLimit(1) + BodySSBText( + data.change.formatted, + textColor: data.change.isPositive ? .greenAccent : .redAccent + ) + .lineLimit(1) } PriceChart(values: data.pastValues, isPositive: data.change.isPositive) diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index b8a341c60..aa082f2e1 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -81,7 +81,7 @@ struct PriceWidgetPreviewView: View { } .navigationBarHidden(true) .padding(.horizontal, 16) - .onAppear { + .task { let options = currentOptions viewModel.fetchPriceData(pairs: options.selectedPairs, period: options.selectedPeriod) } diff --git a/Bitkit/Views/Widgets/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index 4b4b8bbb7..c21c8ad01 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -33,10 +33,10 @@ struct WidgetEditItemView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - if item.type != .staticItem { + if item.type != .staticItem, item.isChecked { Image("check-mark") .resizable() - .foregroundColor(item.isChecked ? .brandAccent : .gray3) + .foregroundColor(.brandAccent) .frame(width: 32, height: 32) } } From a17212970fa7771b1a63909a2290231a58ee032b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:13:38 -0300 Subject: [PATCH 09/45] refactor: simplify doc --- Bitkit/Components/Widgets/PriceWidget.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index c192f4e9c..33e9dd547 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -1,7 +1,7 @@ import Charts import SwiftUI -/// Displays Bitcoin price for the user's selected trading pair and timeframe (Figma v61). +/// Displays Bitcoin price for the user's selected trading pair and timeframe. struct PriceWidget: View { var options: PriceWidgetOptions = .init() var isEditing: Bool = false @@ -43,7 +43,7 @@ struct PriceWidget: View { } } - /// Single pair (v61). Falls back to first available data if the selection isn't loaded yet. + /// Single pair. Falls back to first available data if the selection isn't loaded yet. private var primaryPrice: PriceData? { let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod) if let preferred = options.selectedPairs.first, @@ -135,7 +135,7 @@ struct PriceWidgetCompactContent: View { } } -// MARK: - Chart (line-only per Figma v61) +// MARK: - Chart struct PriceChart: View { let values: [Double] From 73eba60646098867403815ba8552a30d57c61b47 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:15:57 -0300 Subject: [PATCH 10/45] refactor: replace onApper with task --- Bitkit/Components/Widgets/PriceWidget.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index 33e9dd547..b0d832ef8 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -27,7 +27,7 @@ struct PriceWidget: View { ) { content } - .onAppear { fetchPriceData() } + .task { fetchPriceData() } .onChange(of: options.selectedPairs) { fetchPriceData() } .onChange(of: options.selectedPeriod) { fetchPriceData() } } From d07317f89706fe6bb2d8dd7868917eed113308c8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:19:20 -0300 Subject: [PATCH 11/45] refactor: replace onChange with task id --- Bitkit/Components/Widgets/PriceWidget.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index b0d832ef8..f4deacc9b 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -27,9 +27,7 @@ struct PriceWidget: View { ) { content } - .task { fetchPriceData() } - .onChange(of: options.selectedPairs) { fetchPriceData() } - .onChange(of: options.selectedPeriod) { fetchPriceData() } + .task(id: options) { fetchPriceData() } } @ViewBuilder From 8b2ec9728d07ad7a37776cb4515bdc1ed88eb4b6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:41:38 -0300 Subject: [PATCH 12/45] refactor: simplify comments --- Bitkit/Views/Widgets/PriceWidgetPreviewView.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index aa082f2e1..5dbd7c1e9 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -1,10 +1,6 @@ import SwiftUI -/// Preview screen for the Bitcoin Price widget (Figma v61). -/// -/// Replaces the generic `WidgetDetailView` for `.price` only — the other widgets continue to use -/// `WidgetDetailView`. Layout differences from the generic preview: centered top-bar title, -/// description, "Widget Settings" cell, and a Compact ↔ Wide carousel. +/// Preview screen for the Bitcoin Price widget. struct PriceWidgetPreviewView: View { @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var widgets: WidgetsViewModel From 658614369cf5138ddef4395bb9c20da6b11b7df6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:43:32 -0300 Subject: [PATCH 13/45] refactor: simplyfy comments --- Bitkit/Views/Widgets/PriceWidgetPreviewView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index 5dbd7c1e9..9a34abe36 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -47,7 +47,6 @@ struct PriceWidgetPreviewView: View { VStack(alignment: .leading, spacing: 16) { NavigationBar(title: widgetName, showMenuButton: false) - // Content (description + Widget Settings cell with surrounding dividers) VStack(alignment: .leading, spacing: 0) { BodyMText(widgetDescription, textColor: .textSecondary) .padding(.bottom, 16) @@ -59,7 +58,6 @@ struct PriceWidgetPreviewView: View { Divider().background(Color.white.opacity(0.1)) } - // Carousel section (centered widget + size label + page indicator) VStack(spacing: 16) { Spacer(minLength: 0) From fc0dc8ecb7324ab4348bea5307b23dad1ce67c47 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 09:46:20 -0300 Subject: [PATCH 14/45] refactor: simplify comments --- Bitkit/Models/PriceWidgetOptions.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Bitkit/Models/PriceWidgetOptions.swift b/Bitkit/Models/PriceWidgetOptions.swift index 987d838f7..d0bc0e51f 100644 --- a/Bitkit/Models/PriceWidgetOptions.swift +++ b/Bitkit/Models/PriceWidgetOptions.swift @@ -1,9 +1,6 @@ import Foundation /// Options for configuring the in-app and home-screen price widgets (shared via App Group for the extension). -/// -/// `selectedPairs` is kept as an array for storage backwards-compatibility with v60. The v61 UI is -/// single-select and only ever reads/writes `[firstPair]`. struct PriceWidgetOptions: Codable, Equatable { var selectedPairs: [String] = ["BTC/USD"] var selectedPeriod: GraphPeriod = .oneDay From d01c196e1da257c4762646ab71476e521ab9ea12 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 10:00:27 -0300 Subject: [PATCH 15/45] refactor: simplify comments --- Bitkit/Views/Widgets/WidgetEditLogic.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index a991264c1..562864e18 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -157,7 +157,6 @@ class WidgetEditLogic: ObservableObject { onStateChange?() } - /// Single-select per Figma v61 — replaces the array with a single pair. private func selectTradingPair(_ pairName: String) { priceOptions.selectedPairs = [pairName] } From 770511ec7ea2ba203b9789f33bf5a9d5e495675d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 10:17:53 -0300 Subject: [PATCH 16/45] refactor: simplify comments --- BitkitWidget/PriceHomeScreenWidget.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index 68200c5dc..c3de7cd92 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -107,7 +107,6 @@ struct PriceHomeScreenWidgetEntryView: View { } } - /// Always render the first selected pair (v61 is single-pair). private var primaryPrice: PriceData? { let preferred = entry.options.selectedPairs.first if let preferred, let match = entry.prices.first(where: { $0.name == preferred }) { @@ -218,7 +217,7 @@ struct PriceHomeScreenWidgetEntryView: View { } } -// MARK: - Chart (line-only per Figma v61) +// MARK: - Chart private struct PriceWidgetChart: View { let values: [Double] From 5f0841fab2050e3b6f01d9bbb7f802b7a31b80d6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 10:28:45 -0300 Subject: [PATCH 17/45] refactor: remove multi-pair legacy code --- Bitkit/Components/Widgets/PriceWidget.swift | 6 +-- Bitkit/Models/PriceWidgetOptions.swift | 38 ++++++++++++++++++- Bitkit/Utilities/WidgetsBackupConverter.swift | 28 ++++++-------- .../Widgets/PriceWidgetPreviewView.swift | 6 +-- Bitkit/Views/Widgets/WidgetEditLogic.swift | 6 +-- Bitkit/Views/Widgets/WidgetEditModels.swift | 2 +- BitkitWidget/PriceHomeScreenWidget.swift | 9 ++--- 7 files changed, 60 insertions(+), 35 deletions(-) diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index f4deacc9b..0d1323770 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -44,16 +44,14 @@ struct PriceWidget: View { /// Single pair. Falls back to first available data if the selection isn't loaded yet. private var primaryPrice: PriceData? { let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod) - if let preferred = options.selectedPairs.first, - let match = currentPeriodData.first(where: { $0.name == preferred }) - { + if let match = currentPeriodData.first(where: { $0.name == options.selectedPair }) { return match } return currentPeriodData.first } private func fetchPriceData() { - viewModel.fetchPriceData(pairs: options.selectedPairs, period: options.selectedPeriod) + viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) } } diff --git a/Bitkit/Models/PriceWidgetOptions.swift b/Bitkit/Models/PriceWidgetOptions.swift index d0bc0e51f..f4f90dcb0 100644 --- a/Bitkit/Models/PriceWidgetOptions.swift +++ b/Bitkit/Models/PriceWidgetOptions.swift @@ -1,7 +1,41 @@ import Foundation -/// Options for configuring the in-app and home-screen price widgets (shared via App Group for the extension). +/// Options for configuring the in-app and home-screen price widgets (shared via App Group). +/// struct PriceWidgetOptions: Codable, Equatable { - var selectedPairs: [String] = ["BTC/USD"] + var selectedPair: String = "BTC/USD" var selectedPeriod: GraphPeriod = .oneDay + + init(selectedPair: String = "BTC/USD", selectedPeriod: GraphPeriod = .oneDay) { + self.selectedPair = selectedPair + self.selectedPeriod = selectedPeriod + } + + private enum CodingKeys: String, CodingKey { + case selectedPair + case selectedPairs // legacy v60 key + case selectedPeriod + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let pair = try container.decodeIfPresent(String.self, forKey: .selectedPair) { + selectedPair = pair + } else if let legacyPairs = try container.decodeIfPresent([String].self, forKey: .selectedPairs), + let first = legacyPairs.first + { + selectedPair = first + } else { + selectedPair = "BTC/USD" + } + + selectedPeriod = try container.decodeIfPresent(GraphPeriod.self, forKey: .selectedPeriod) ?? .oneDay + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(selectedPair, forKey: .selectedPair) + try container.encode(selectedPeriod, forKey: .selectedPeriod) + } } diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index f1ba83a71..6d0e392c8 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -59,12 +59,10 @@ enum WidgetsBackupConverter { } case .price: if let options = try? JSONDecoder().decode(PriceWidgetOptions.self, from: optionsData) { - let androidPairs = options.selectedPairs.map { pair in - pair.replacingOccurrences(of: "/", with: "_") - } + let androidPair = options.selectedPair.replacingOccurrences(of: "/", with: "_") let androidPeriod = convertIosPeriodToAndroid(options.selectedPeriod) pricePreferences = [ - "enabledPairs": androidPairs.isEmpty ? ["BTC_USD"] : androidPairs, + "enabledPairs": [androidPair.isEmpty ? "BTC_USD" : androidPair], "period": androidPeriod, ] } @@ -165,19 +163,19 @@ enum WidgetsBackupConverter { } case .price: if let prefs = jsonDict["pricePreferences"] as? [String: Any] { - var selectedPairs = ["BTC/USD"] - if let pairsArray = prefs["enabledPairs"] as? [String] { - selectedPairs = pairsArray.map { pairType in - pairType.replacingOccurrences(of: "_", with: "/") - } - if selectedPairs.isEmpty { - selectedPairs = ["BTC/USD"] + var selectedPair = "BTC/USD" + if let pairsArray = prefs["enabledPairs"] as? [String], + let firstAndroidPair = pairsArray.first + { + let converted = firstAndroidPair.replacingOccurrences(of: "_", with: "/") + if !converted.isEmpty { + selectedPair = converted } } let period = convertAndroidPeriodToIos(prefs["period"] as? String) let iosOptions = PriceWidgetOptions( - selectedPairs: selectedPairs, + selectedPair: selectedPair, selectedPeriod: period ) optionsData = try? JSONEncoder().encode(iosOptions) @@ -234,12 +232,10 @@ enum WidgetsBackupConverter { private static func getDefaultPricePreferences() -> [String: Any] { let defaults = PriceWidgetOptions() - let androidPairs = defaults.selectedPairs.map { pair in - pair.replacingOccurrences(of: "/", with: "_") - } + let androidPair = defaults.selectedPair.replacingOccurrences(of: "/", with: "_") let androidPeriod = convertIosPeriodToAndroid(defaults.selectedPeriod) return [ - "enabledPairs": androidPairs.isEmpty ? ["BTC_USD"] : androidPairs, + "enabledPairs": [androidPair.isEmpty ? "BTC_USD" : androidPair], "period": androidPeriod, ] } diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index 9a34abe36..afead9ce9 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -35,9 +35,7 @@ struct PriceWidgetPreviewView: View { private var primaryPrice: PriceData? { let options = currentOptions let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod) - if let preferred = options.selectedPairs.first, - let match = currentPeriodData.first(where: { $0.name == preferred }) - { + if let match = currentPeriodData.first(where: { $0.name == options.selectedPair }) { return match } return currentPeriodData.first @@ -77,7 +75,7 @@ struct PriceWidgetPreviewView: View { .padding(.horizontal, 16) .task { let options = currentOptions - viewModel.fetchPriceData(pairs: options.selectedPairs, period: options.selectedPeriod) + viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) } .alert( t("widgets__delete__title"), diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index 562864e18..e6b806683 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -44,8 +44,8 @@ class WidgetEditLogic: ObservableObject { // Weather widget has multiple options, check if any are enabled return weatherOptions.showStatus || weatherOptions.showText || weatherOptions.showMedian || weatherOptions.showNextBlockFee case .price: - // Price widget has options, check if at least one trading pair is selected - return !priceOptions.selectedPairs.isEmpty + // Price widget always has a selected pair (single-select). + return true case .calculator, .suggestions: return false } @@ -158,7 +158,7 @@ class WidgetEditLogic: ObservableObject { } private func selectTradingPair(_ pairName: String) { - priceOptions.selectedPairs = [pairName] + priceOptions.selectedPair = pairName } func loadCurrentOptions() { diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index fa1db48ca..76ff00a6b 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -380,7 +380,7 @@ enum WidgetEditItemFactory { // CURRENCY section (single-select) items.append(sectionHeaderItem(key: "currency_header", title: t("widgets__price__currency"))) - let selectedPair = priceOptions.selectedPairs.first + let selectedPair = priceOptions.selectedPair for pair in tradingPairNames { let isSelected = selectedPair == pair items.append( diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index c3de7cd92..724faf2e1 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -50,7 +50,7 @@ struct PriceWidgetProvider: TimelineProvider { return } - let cached = PriceWidgetService.cachedPrices(pairs: options.selectedPairs, period: options.selectedPeriod) ?? [] + let cached = PriceWidgetService.cachedPrices(pairs: [options.selectedPair], period: options.selectedPeriod) ?? [] completion(PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: false)) } @@ -61,12 +61,12 @@ struct PriceWidgetProvider: TimelineProvider { let entry: PriceWidgetEntry do { let fresh = try await PriceWidgetService.fetchFreshPrices( - pairs: options.selectedPairs, + pairs: [options.selectedPair], period: options.selectedPeriod ) entry = PriceWidgetEntry(date: Date(), prices: fresh, options: options, showsError: false) } catch { - let cached = PriceWidgetService.cachedPrices(pairs: options.selectedPairs, period: options.selectedPeriod) ?? [] + let cached = PriceWidgetService.cachedPrices(pairs: [options.selectedPair], period: options.selectedPeriod) ?? [] entry = PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: cached.isEmpty) } @@ -108,8 +108,7 @@ struct PriceHomeScreenWidgetEntryView: View { } private var primaryPrice: PriceData? { - let preferred = entry.options.selectedPairs.first - if let preferred, let match = entry.prices.first(where: { $0.name == preferred }) { + if let match = entry.prices.first(where: { $0.name == entry.options.selectedPair }) { return match } return entry.prices.first From 51c2f4130e8083158419f766534db8d68c336039 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 10:52:06 -0300 Subject: [PATCH 18/45] fix: fallback to os widget options after remove in-app --- Bitkit/ViewModels/WidgetsViewModel.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 67a6a804e..ee0edad19 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -232,6 +232,10 @@ class WidgetsViewModel: ObservableObject { return options } + if type == .price, let priceOptions = PriceHomeScreenWidgetOptionsStore.load() as? T { + return priceOptions + } + // Return default options if none saved return getDefaultOptions(for: type) as! T } From afb421ad2f6ddf53c18971043380db0610357d9c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 11:10:09 -0300 Subject: [PATCH 19/45] fix: make chart height adaptable --- BitkitWidget/PriceHomeScreenWidget.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index 724faf2e1..55ab2b7c0 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -117,7 +117,7 @@ struct PriceHomeScreenWidgetEntryView: View { // MARK: - Compact (small widget — 163×192) private func compactLayout(data: PriceData) -> some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 0) { captionUpText(data.name) @@ -134,15 +134,17 @@ struct PriceHomeScreenWidgetEntryView: View { .widgetAccentable() } - chart(values: data.pastValues, isPositive: data.change.isPositive, height: 64) + Spacer(minLength: 8) + + chart(values: data.pastValues, isPositive: data.change.isPositive, idealHeight: 64) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - // MARK: - Wide (medium / large widget) + // MARK: - Wide (medium widget — 343×152) private func wideLayout(data: PriceData) -> some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 4) { HStack(alignment: .center, spacing: 16) { captionUpText("\(data.name) \(entry.options.selectedPeriod.rawValue)") @@ -158,7 +160,9 @@ struct PriceHomeScreenWidgetEntryView: View { priceText(data.price, size: 34, lineHeight: 34) } - chart(values: data.pastValues, isPositive: data.change.isPositive, height: 48) + Spacer(minLength: 4) + + chart(values: data.pastValues, isPositive: data.change.isPositive, idealHeight: 48, minHeight: 24) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @@ -181,13 +185,13 @@ struct PriceHomeScreenWidgetEntryView: View { .widgetAccentable() } - private func chart(values: [Double], isPositive: Bool, height: CGFloat) -> some View { + private func chart(values: [Double], isPositive: Bool, idealHeight: CGFloat, minHeight: CGFloat = 32) -> some View { PriceWidgetChart( values: values, isPositive: isPositive, renderingMode: widgetRenderingMode ) - .frame(height: height) + .frame(minHeight: minHeight, maxHeight: idealHeight) .widgetAccentable() } From 5be60140abacd71b66749b9c5daf499cded7ad99 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 13:31:14 -0300 Subject: [PATCH 20/45] feat: set backgroud color Gray7 --- Bitkit/Components/NavigationBar.swift | 19 ++++++++++++++----- .../Widgets/PriceWidgetPreviewView.swift | 4 +++- Bitkit/Views/Widgets/WidgetEditView.swift | 5 ++++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/Bitkit/Components/NavigationBar.swift b/Bitkit/Components/NavigationBar.swift index 36fb13dbb..787334442 100644 --- a/Bitkit/Components/NavigationBar.swift +++ b/Bitkit/Components/NavigationBar.swift @@ -7,6 +7,7 @@ struct NavigationBar: View { let title: String let showBackButton: Bool let showMenuButton: Bool + let showGradient: Bool let action: AnyView? let icon: String? let onBack: (() -> Void)? @@ -15,6 +16,7 @@ struct NavigationBar: View { title: String, showBackButton: Bool = true, showMenuButton: Bool = true, + showGradient: Bool = true, action: AnyView? = nil, icon: String? = nil, onBack: (() -> Void)? = nil @@ -22,6 +24,7 @@ struct NavigationBar: View { self.title = title self.showBackButton = showBackButton self.showMenuButton = showMenuButton + self.showGradient = showGradient self.action = action self.icon = icon self.onBack = onBack @@ -89,11 +92,17 @@ struct NavigationBar: View { } } .frame(height: 48) - .background(LinearGradient( - colors: [.black, .black.opacity(0)], - startPoint: .top, - endPoint: .bottom - )) + .background( + Group { + if showGradient { + LinearGradient( + colors: [.black, .black.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + } + } + ) .zIndex(.infinity) } } diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index afead9ce9..54d7bca05 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -43,7 +43,7 @@ struct PriceWidgetPreviewView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - NavigationBar(title: widgetName, showMenuButton: false) + NavigationBar(title: widgetName, showMenuButton: false, showGradient: false) VStack(alignment: .leading, spacing: 0) { BodyMText(widgetDescription, textColor: .textSecondary) @@ -73,6 +73,8 @@ struct PriceWidgetPreviewView: View { } .navigationBarHidden(true) .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray7.ignoresSafeArea()) .task { let options = currentOptions viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index e119ee310..4c3e81a88 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -59,7 +59,8 @@ struct WidgetEditView: View { VStack(alignment: .leading, spacing: 0) { NavigationBar( title: id == .price ? widget.name : t("widgets__widget__edit"), - showMenuButton: id != .price + showMenuButton: id != .price, + showGradient: id != .price ) .padding(.bottom, 16) @@ -112,6 +113,8 @@ struct WidgetEditView: View { } .navigationBarHidden(true) .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray7.ignoresSafeArea()) .onAppear { if editLogic == nil { let logic = WidgetEditLogic(widgetType: id, widgetsViewModel: widgets) From d60c84cc7299d43f1bf3fd40ab376c365a07801f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 06:33:06 -0300 Subject: [PATCH 21/45] feat: migrate news widget to design v61 and port OS widget --- .claude/scheduled_tasks.lock | 1 + Bitkit.xcodeproj/project.pbxproj | 3 + Bitkit/Components/Widgets/NewsWidget.swift | 116 +++++--- Bitkit/MainNavView.swift | 7 +- Bitkit/Models/NewsWidgetData.swift | 40 +++ Bitkit/Models/NewsWidgetOptions.swift | 14 + .../Localization/en.lproj/Localizable.strings | 4 + .../NewsHomeScreenWidgetOptionsStore.swift | 36 +++ Bitkit/Services/Widgets/NewsService.swift | 112 +++----- Bitkit/ViewModels/WidgetsViewModel.swift | 13 + .../Views/Widgets/NewsWidgetPreviewView.swift | 250 ++++++++++++++++ Bitkit/Views/Widgets/WidgetEditModels.swift | 40 +-- BitkitWidget/BitkitWidget.swift | 1 + BitkitWidget/NewsHomeScreenWidget.swift | 266 ++++++++++++++++++ BitkitWidget/NewsWidgetService.swift | 60 ++++ 15 files changed, 836 insertions(+), 127 deletions(-) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 Bitkit/Models/NewsWidgetData.swift create mode 100644 Bitkit/Models/NewsWidgetOptions.swift create mode 100644 Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift create mode 100644 Bitkit/Views/Widgets/NewsWidgetPreviewView.swift create mode 100644 BitkitWidget/NewsHomeScreenWidget.swift create mode 100644 BitkitWidget/NewsWidgetService.swift diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 000000000..ae98bc612 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"53eebf98-950c-4963-804d-ef8de7c14fe4","pid":5145,"procStart":"Wed May 6 16:43:05 2026","acquiredAt":1778087089693} \ No newline at end of file diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 799da4457..15e22deb6 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -173,8 +173,11 @@ Fonts/InterTight-Regular.ttf, Constants/WidgetEnv.swift, Fonts/InterTight-SemiBold.ttf, + Models/NewsWidgetData.swift, + Models/NewsWidgetOptions.swift, Models/PriceWidgetData.swift, Models/PriceWidgetOptions.swift, + Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift, Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift, Styles/Colors.swift, Styles/Fonts.swift, diff --git a/Bitkit/Components/Widgets/NewsWidget.swift b/Bitkit/Components/Widgets/NewsWidget.swift index 1a64e748d..35295183b 100644 --- a/Bitkit/Components/Widgets/NewsWidget.swift +++ b/Bitkit/Components/Widgets/NewsWidget.swift @@ -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 (Figma v61). 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, @@ -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) } + } + } + .onAppear { + 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) } - .contentShape(Rectangle()) - .onTapGesture { - if !isEditing, let data = viewModel.widgetData, let url = URL(string: data.link) { - UIApplication.shared.open(url) + } + .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) + } + + 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) } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 2a17eaa70..3bcb39cd6 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -432,9 +432,12 @@ struct MainNavView: View { case .widgetsIntro: WidgetsIntroView() case .widgetsList: WidgetsListView() case let .widgetDetail(widgetType): - if widgetType == .price { + switch widgetType { + case .price: PriceWidgetPreviewView() - } else { + case .news: + NewsWidgetPreviewView() + default: WidgetDetailView(id: widgetType) } case let .widgetEdit(widgetType): WidgetEditView(id: widgetType) diff --git a/Bitkit/Models/NewsWidgetData.swift b/Bitkit/Models/NewsWidgetData.swift new file mode 100644 index 000000000..1db970135 --- /dev/null +++ b/Bitkit/Models/NewsWidgetData.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Persistable representation of a news article shared between the main app and the widget extension via App Group. +struct CachedNewsArticle: Codable, Equatable { + let title: String + let publisher: String + let link: String + let publishedDate: String + let publishedEpoch: Int +} + +/// Cache reader/writer used by both the main app and the widget extension. +enum NewsWidgetCache { + static let appGroupSuiteName = "group.bitkit" + private static let topArticlesKey = "news_widget_top_articles_v1" + private static let legacyStandardKey = "news_widget_cache" + + private static func defaults() -> UserDefaults { + UserDefaults(suiteName: appGroupSuiteName) ?? .standard + } + + static func saveTop(_ articles: [CachedNewsArticle]) { + guard let encoded = try? JSONEncoder().encode(articles) else { return } + defaults().set(encoded, forKey: topArticlesKey) + } + + static func loadTop() -> [CachedNewsArticle] { + guard let data = defaults().data(forKey: topArticlesKey), + let decoded = try? JSONDecoder().decode([CachedNewsArticle].self, from: data) + else { + return [] + } + return decoded + } + + /// One-time cleanup of the pre-App-Group single-`WidgetData` cache. + static func legacyDropStandardSuiteCache() { + UserDefaults.standard.removeObject(forKey: legacyStandardKey) + } +} diff --git a/Bitkit/Models/NewsWidgetOptions.swift b/Bitkit/Models/NewsWidgetOptions.swift new file mode 100644 index 000000000..4497b6b50 --- /dev/null +++ b/Bitkit/Models/NewsWidgetOptions.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Options for configuring the in-app and home-screen news widgets (shared via App Group). +struct NewsWidgetOptions: Codable, Equatable { + var showDate: Bool = true + var showTitle: Bool = true + var showSource: Bool = true + + init(showDate: Bool = true, showTitle: Bool = true, showSource: Bool = true) { + self.showDate = showDate + self.showTitle = showTitle + self.showSource = showSource + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index afad97a20..c32f9de47 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1404,6 +1404,10 @@ "widgets__news__name" = "Bitcoin Headlines"; "widgets__news__description" = "Read the latest & greatest Bitcoin headlines from various news sites."; "widgets__news__error" = "Couldn\'t get the latest news"; +"widgets__news__size_small" = "Small"; +"widgets__news__size_wide" = "Wide"; +"widgets__news__widget_settings" = "Widget Settings"; +"widgets__news__content_header" = "Content"; "widgets__blocks__name" = "Bitcoin Blocks"; "widgets__blocks__description" = "Examine various statistics on newly mined Bitcoin Blocks."; "widgets__blocks__error" = "Couldn\'t get blocks data"; diff --git a/Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift new file mode 100644 index 000000000..043d161dd --- /dev/null +++ b/Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift @@ -0,0 +1,36 @@ +import Foundation +import WidgetKit + +/// Mirrors in-app news widget options into the App Group so the WidgetKit extension can read them, +/// and centralizes the WidgetKit reload trigger for the news home-screen widget. +enum NewsHomeScreenWidgetOptionsStore { + /// WidgetKit `kind` for the home-screen news widget (must match `BitkitNewsWidget`). + static let newsHomeScreenWidgetKind = "BitkitNewsWidget" + + private static let suiteName = "group.bitkit" + private static let key = "home_screen_news_widget_options_v1" + + static func save(_ options: NewsWidgetOptions) { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = try? JSONEncoder().encode(options) + else { return } + defaults.set(data, forKey: key) + } + + static func load() -> NewsWidgetOptions { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = defaults.data(forKey: key), + let options = try? JSONDecoder().decode(NewsWidgetOptions.self, from: data) + else { + return NewsWidgetOptions() + } + 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: newsHomeScreenWidgetKind) + } +} diff --git a/Bitkit/Services/Widgets/NewsService.swift b/Bitkit/Services/Widgets/NewsService.swift index e02681ab3..2d6d9aca7 100644 --- a/Bitkit/Services/Widgets/NewsService.swift +++ b/Bitkit/Services/Widgets/NewsService.swift @@ -3,12 +3,12 @@ import Foundation /// Service for fetching and caching news articles class NewsService { static let shared = NewsService() - private let cache = UserDefaults.standard - private let cacheKey = "news_widget_cache" private let baseUrl = "https://feeds.synonym.to/news-feed/api" private let refreshInterval: TimeInterval = 2 * 60 // 2 minutes - private init() {} + private init() { + NewsWidgetCache.legacyDropStandardSuiteCache() + } /// Fetches articles from the news API /// - Returns: Array of articles @@ -20,7 +20,6 @@ class NewsService { let (data, response) = try await URLSession.shared.data(from: url) - // Validate HTTP response guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } @@ -29,39 +28,18 @@ class NewsService { throw URLError(.badServerResponse) } - do { - let decoder = JSONDecoder() - return try decoder.decode([Article].self, from: data) - } catch { - throw error - } + return try JSONDecoder().decode([Article].self, from: data) } - /// Caches widget data to UserDefaults - /// - Parameter data: Widget data to cache - func cacheData(_ data: WidgetData) { - do { - let encoder = JSONEncoder() - let encoded = try encoder.encode(data) - cache.set(encoded, forKey: cacheKey) - } catch { - // Handle silently - } - } - - /// Retrieves cached widget data - /// - Returns: Widget data if available + /// Retrieves a cached widget data view by selecting a random article from the App Group cache. func getCachedData() -> WidgetData? { - guard let data = cache.data(forKey: cacheKey) else { - return nil - } - - do { - let decoder = JSONDecoder() - return try decoder.decode(WidgetData.self, from: data) - } catch { - return nil - } + guard let article = NewsWidgetCache.loadTop().randomElement() else { return nil } + return WidgetData( + title: article.title, + timeAgo: timeAgo(from: article.publishedDate), + link: article.link, + publisher: article.publisher + ) } /// Converts a date string to a human-readable time ago format @@ -83,60 +61,60 @@ class NewsService { return relativeFormatter.localizedString(for: date, relativeTo: Date()) } + /// Fetches the top 10 most recent articles, persists them to the App Group cache, + /// and triggers a home-screen widget reload. + @discardableResult + func fetchTopArticles() async throws -> [CachedNewsArticle] { + let articles = try await fetchArticles() + let top = articles + .sorted { $0.published > $1.published } + .prefix(10) + .map { article in + CachedNewsArticle( + title: article.title, + publisher: article.publisher.title, + link: article.comments ?? article.link, + publishedDate: article.publishedDate, + publishedEpoch: article.published + ) + } + + NewsWidgetCache.saveTop(top) + NewsHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() + + return top + } + /// Fetches widget data using stale-while-revalidate strategy /// - Parameter returnCachedImmediately: If true, returns cached data immediately if available /// - Returns: Widget data /// - Throws: URLError or decoding error @discardableResult func fetchWidgetData(returnCachedImmediately: Bool = true) async throws -> WidgetData { - // If we want cached data and it exists, return it immediately if returnCachedImmediately, let cachedData = getCachedData() { - // Start fresh fetch in background to update cache (don't await) + // Refresh in background; cache is updated automatically. Task { do { - try await fetchFreshData() - // Cache will be updated automatically in fetchFreshData + try await fetchTopArticles() } catch { - // Silent failure for background updates print("Background news 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) - @discardableResult - private func fetchFreshData() async throws -> WidgetData { - let articles = try await fetchArticles() - - // Get a random article from the last 10 - let recentArticles = - articles - .sorted { $0.published > $1.published } - .prefix(10) - - guard let article = recentArticles.randomElement() else { + let top = try await fetchTopArticles() + guard let article = top.randomElement() else { Logger.error("No articles available after filtering") throw URLError(.cannotParseResponse) } - let timeAgoString = timeAgo(from: article.publishedDate) - - let widgetData = WidgetData( + return WidgetData( title: article.title, - timeAgo: timeAgoString, - link: article.comments ?? article.link, - publisher: article.publisher.title + timeAgo: timeAgo(from: article.publishedDate), + link: article.link, + publisher: article.publisher ) - - // Cache the data - cacheData(widgetData) - - return widgetData } } @@ -201,7 +179,7 @@ struct Publisher: Codable { let image: String? } -/// Widget data model for caching +/// Widget data model used by the in-app news widget UI. struct WidgetData: Codable { let title: String let timeAgo: String diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index ee0edad19..3a29f381e 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -236,6 +236,10 @@ class WidgetsViewModel: ObservableObject { return priceOptions } + if type == .news, let newsOptions = NewsHomeScreenWidgetOptionsStore.load() as? T { + return newsOptions + } + // Return default options if none saved return getDefaultOptions(for: type) as! T } @@ -306,6 +310,7 @@ class WidgetsViewModel: ObservableObject { persistSavedWidgets() } syncPriceOptionsToHomeScreenWidget() + syncNewsOptionsToHomeScreenWidget() } private func persistSavedWidgets() { @@ -316,6 +321,7 @@ class WidgetsViewModel: ObservableObject { print("Failed to persist widgets: \(error)") } syncPriceOptionsToHomeScreenWidget() + syncNewsOptionsToHomeScreenWidget() } /// Keeps the home-screen WidgetKit price widget in sync with in-app price widget options (App Group). @@ -324,4 +330,11 @@ class WidgetsViewModel: ObservableObject { PriceHomeScreenWidgetOptionsStore.save(options) PriceHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } + + /// Keeps the home-screen WidgetKit news widget in sync with in-app news widget options (App Group). + private func syncNewsOptionsToHomeScreenWidget() { + let options: NewsWidgetOptions = getOptions(for: .news, as: NewsWidgetOptions.self) + NewsHomeScreenWidgetOptionsStore.save(options) + NewsHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() + } } diff --git a/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift b/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift new file mode 100644 index 000000000..0182d3d46 --- /dev/null +++ b/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift @@ -0,0 +1,250 @@ +import SwiftUI + +/// Preview screen for the Bitcoin Headlines widget. +struct NewsWidgetPreviewView: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + + @StateObject private var viewModel = NewsViewModel.shared + + @State private var carouselPage: Int = 0 + @State private var showDeleteAlert = false + + private let widgetType: WidgetType = .news + + private var widgetName: String { + t("widgets__news__name") + } + + private var widgetDescription: String { + t("widgets__news__description") + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(widgetType) + } + + private var hasCustomOptions: Bool { + widgets.hasCustomOptions(for: widgetType) + } + + private var currentOptions: NewsWidgetOptions { + widgets.getOptions(for: widgetType, as: NewsWidgetOptions.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()) + .onAppear { + 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__news__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.widgetData { + NewsWidgetCompactContent(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.widgetData { + NewsWidgetWideContent(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: 118) + .overlay(ProgressView()) + } + + // MARK: - Size label & page indicator + + private var sizeLabel: some View { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__news__size_small") + : t("widgets__news__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 { + NewsWidgetPreviewView() + .environmentObject(NavigationViewModel()) + .environmentObject(WidgetsViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 76ff00a6b..b7a30e3e6 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -307,17 +307,9 @@ enum WidgetEditItemFactory { ) -> [WidgetEditItem] { var items: [WidgetEditItem] = [] - if let data = newsViewModel.widgetData { - items.append( - WidgetEditItem( - key: "showDate", - type: .toggleItem, - titleView: AnyView(BodyMText(data.timeAgo, textColor: .textPrimary)), - valueView: nil, - isChecked: newsOptions.showDate - ) - ) + items.append(sectionHeaderItem(key: "news_content_header", title: t("widgets__news__content_header"))) + if let data = newsViewModel.widgetData { items.append( WidgetEditItem( key: "showTitle", @@ -332,28 +324,28 @@ enum WidgetEditItemFactory { WidgetEditItem( key: "showSource", type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText(data.publisher, textColor: .textSecondary)), + titleView: AnyView(BodySSBText(data.publisher, textColor: .brandAccent)), + valueView: nil, isChecked: newsOptions.showSource ) ) - } else { - // Fallback when no data is available + items.append( WidgetEditItem( key: "showDate", type: .toggleItem, - titleView: AnyView(BodyMText("13 hours ago", textColor: .textPrimary)), + titleView: AnyView(BodySSBText(data.timeAgo, textColor: .textSecondary)), valueView: nil, isChecked: newsOptions.showDate ) ) - + } else { + // Fallback when no data is available items.append( WidgetEditItem( key: "showTitle", type: .staticItem, - titleView: AnyView(TitleText("Exodus Launches XO Pay, An In-App Bitcoin And Crypto Purchase Solution")), + titleView: AnyView(TitleText("How Bitcoin changed El Salvador in more ways...")), valueView: nil, isChecked: true // Static items are always shown ) @@ -363,11 +355,21 @@ enum WidgetEditItemFactory { WidgetEditItem( key: "showSource", type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("Bitcoin Magazine", textColor: .textSecondary)), + titleView: AnyView(BodySSBText("bitcoinmagazine.com", textColor: .brandAccent)), + valueView: nil, isChecked: newsOptions.showSource ) ) + + items.append( + WidgetEditItem( + key: "showDate", + type: .toggleItem, + titleView: AnyView(BodySSBText("1 min ago", textColor: .textSecondary)), + valueView: nil, + isChecked: newsOptions.showDate + ) + ) } return items diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift index 737864ecf..ba9bbdd06 100644 --- a/BitkitWidget/BitkitWidget.swift +++ b/BitkitWidget/BitkitWidget.swift @@ -5,5 +5,6 @@ import WidgetKit struct BitkitWidgetBundle: WidgetBundle { var body: some Widget { BitkitPriceWidget() + BitkitNewsWidget() } } diff --git a/BitkitWidget/NewsHomeScreenWidget.swift b/BitkitWidget/NewsHomeScreenWidget.swift new file mode 100644 index 000000000..0e0d894d0 --- /dev/null +++ b/BitkitWidget/NewsHomeScreenWidget.swift @@ -0,0 +1,266 @@ +import SwiftUI +import WidgetKit + +// MARK: - Entry + +struct NewsWidgetEntry: TimelineEntry { + let date: Date + let article: CachedNewsArticle? + let timeAgo: String + let options: NewsWidgetOptions + /// 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 NewsWidgetEntryBuilder { + static func relativeTime(from dateString: String) -> String { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" + guard let date = formatter.date(from: dateString) else { return "" } + + let relative = RelativeDateTimeFormatter() + relative.locale = Locale.current + relative.dateTimeStyle = .named + return relative.localizedString(for: date, relativeTo: Date()) + } +} + +// MARK: - Timeline Provider + +struct NewsWidgetProvider: TimelineProvider { + /// Stable mock for widget gallery / placeholder snapshots. + private static let mockArticle = CachedNewsArticle( + title: "How Bitcoin changed El Salvador in more ways than one", + publisher: "bitcoinmagazine.com", + link: "https://bitcoinmagazine.com", + publishedDate: "Mon, 01 Jan 2024 12:00:00 +0000", + publishedEpoch: 1_704_110_400 + ) + + private static let mockEntry = NewsWidgetEntry( + date: Date(), + article: mockArticle, + timeAgo: "21 min ago", + options: NewsWidgetOptions(), + showsError: false + ) + + func placeholder(in _: Context) -> NewsWidgetEntry { + Self.mockEntry + } + + func getSnapshot(in context: Context, completion: @escaping (NewsWidgetEntry) -> Void) { + let options = NewsHomeScreenWidgetOptionsStore.load() + + if context.isPreview { + completion(NewsWidgetEntry( + date: Self.mockEntry.date, + article: Self.mockArticle, + timeAgo: Self.mockEntry.timeAgo, + options: options, + showsError: false + )) + return + } + + let cached = NewsWidgetService.cachedTopArticles() + let pick = cached.randomElement() + completion(NewsWidgetEntry( + date: Date(), + article: pick, + timeAgo: pick.map { NewsWidgetEntryBuilder.relativeTime(from: $0.publishedDate) } ?? "", + options: options, + showsError: false + )) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let options = NewsHomeScreenWidgetOptionsStore.load() + + Task { + let entry: NewsWidgetEntry + do { + let fresh = try await NewsWidgetService.fetchFreshTopArticles() + if let pick = fresh.randomElement() { + entry = NewsWidgetEntry( + date: Date(), + article: pick, + timeAgo: NewsWidgetEntryBuilder.relativeTime(from: pick.publishedDate), + options: options, + showsError: false + ) + } else { + entry = NewsWidgetEntry(date: Date(), article: nil, timeAgo: "", options: options, showsError: true) + } + } catch { + let cached = NewsWidgetService.cachedTopArticles() + if let pick = cached.randomElement() { + entry = NewsWidgetEntry( + date: Date(), + article: pick, + timeAgo: NewsWidgetEntryBuilder.relativeTime(from: pick.publishedDate), + options: options, + showsError: false + ) + } else { + entry = NewsWidgetEntry(date: Date(), article: nil, timeAgo: "", options: options, showsError: true) + } + } + + let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) + ?? Date().addingTimeInterval(15 * 60) + completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + } + } +} + +// MARK: - View + +struct NewsHomeScreenWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode + + var entry: NewsWidgetProvider.Entry + + var body: some View { + Group { + if let url = articleURL { + Link(destination: url) { content } + } else { + content + } + } + .containerBackground(for: .widget) { backgroundView } + } + + private var articleURL: URL? { + guard let link = entry.article?.link else { return nil } + return URL(string: link) + } + + @ViewBuilder + private var content: some View { + if entry.showsError { + errorView + } else if let article = entry.article { + switch widgetFamily { + case .systemSmall: + compactLayout(article: article) + default: + wideLayout(article: article) + } + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + + // MARK: - Compact (small widget — 163×192) + + private func compactLayout(article: CachedNewsArticle) -> some View { + VStack(alignment: .leading, spacing: 0) { + titleText(article.title) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer(minLength: 8) + + if entry.options.showDate { + HStack { + Spacer(minLength: 0) + Text(entry.timeAgo) + .font(Fonts.semiBold(size: 13)) + .tracking(0.4) + .foregroundColor(secondaryTextColor) + .lineLimit(1) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Wide (medium widget — 343×118) + + private func wideLayout(article: CachedNewsArticle) -> some View { + VStack(alignment: .leading, spacing: 16) { + titleText(article.title) + .frame(maxWidth: .infinity, alignment: .leading) + + if entry.options.showSource || entry.options.showDate { + HStack(alignment: .center, spacing: 8) { + if entry.options.showSource { + Text(article.publisher) + .font(Fonts.semiBold(size: 13)) + .tracking(0.4) + .foregroundColor(sourceTextColor) + .lineLimit(1) + } + Spacer(minLength: 0) + if entry.options.showDate { + Text(entry.timeAgo) + .font(Fonts.semiBold(size: 13)) + .tracking(0.4) + .foregroundColor(secondaryTextColor) + .lineLimit(1) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Sub-views + + private func titleText(_ value: String) -> some View { + Text(value) + .font(Fonts.bold(size: 22)) + .foregroundColor(titleTextColor) + .lineLimit(4) + .minimumScaleFactor(0.85) + .widgetAccentable() + } + + private var errorView: some View { + Text("Couldn’t load headlines.") + .font(Fonts.regular(size: 13)) + .foregroundColor(secondaryTextColor) + .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 sourceTextColor: Color { + guard widgetRenderingMode == .fullColor else { return .primary } + return .brandAccent + } + + private var secondaryTextColor: Color { + widgetRenderingMode == .fullColor ? .white.opacity(0.64) : .secondary + } +} + +// MARK: - Widget Configuration + +struct BitkitNewsWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration( + kind: NewsHomeScreenWidgetOptionsStore.newsHomeScreenWidgetKind, + provider: NewsWidgetProvider() + ) { entry in + NewsHomeScreenWidgetEntryView(entry: entry) + } + .configurationDisplayName("Bitcoin Headlines") + .description("Latest Bitcoin news headlines, mirroring the in-app headlines widget.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} diff --git a/BitkitWidget/NewsWidgetService.swift b/BitkitWidget/NewsWidgetService.swift new file mode 100644 index 000000000..f97f3e2b5 --- /dev/null +++ b/BitkitWidget/NewsWidgetService.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Slim news fetcher used inside the WidgetKit extension. +/// +/// Reads cached `[CachedNewsArticle]` from the App Group (written by the main app's `NewsService`) +/// and falls back to a direct network fetch when the cache is empty or stale. The cache itself +/// is owned by the main app; this service intentionally does not write back to it. +enum NewsWidgetService { + enum FetchError: Error { + case invalidURL + case noArticlesAvailable + } + + private static let baseUrl = "https://feeds.synonym.to/news-feed/api" + + static func cachedTopArticles() -> [CachedNewsArticle] { + NewsWidgetCache.loadTop() + } + + static func fetchFreshTopArticles() async throws -> [CachedNewsArticle] { + guard let url = URL(string: "\(baseUrl)/articles") else { throw FetchError.invalidURL } + + let (data, _) = try await URLSession.shared.data(from: url) + let articles = try JSONDecoder().decode([WireArticle].self, from: data) + + let top = articles + .sorted { $0.published > $1.published } + .prefix(10) + .map { wire in + CachedNewsArticle( + title: wire.title, + publisher: wire.publisher.title, + link: wire.comments ?? wire.link, + publishedDate: wire.publishedDate, + publishedEpoch: wire.published + ) + } + + guard !top.isEmpty else { throw FetchError.noArticlesAvailable } + return top + } +} + +// MARK: - Wire Models + +/// Local copy to keep the widget extension's footprint small (mirrors `Article` in main app). +private struct WireArticle: Codable { + let title: String + let published: Int + let publishedDate: String + let link: String + let comments: String? + let publisher: WirePublisher +} + +private struct WirePublisher: Codable { + let title: String + let link: String + let image: String? +} From ab5b8d58d5d9b3fa4c4fe661bc6cd8d33d4f1015 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 06:37:07 -0300 Subject: [PATCH 22/45] fix: push source text to bottom --- BitkitWidget/NewsHomeScreenWidget.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/BitkitWidget/NewsHomeScreenWidget.swift b/BitkitWidget/NewsHomeScreenWidget.swift index 0e0d894d0..40bdeb56a 100644 --- a/BitkitWidget/NewsHomeScreenWidget.swift +++ b/BitkitWidget/NewsHomeScreenWidget.swift @@ -184,10 +184,12 @@ struct NewsHomeScreenWidgetEntryView: View { // MARK: - Wide (medium widget — 343×118) private func wideLayout(article: CachedNewsArticle) -> some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 0) { titleText(article.title) .frame(maxWidth: .infinity, alignment: .leading) + Spacer(minLength: 8) + if entry.options.showSource || entry.options.showDate { HStack(alignment: .center, spacing: 8) { if entry.options.showSource { From 66f82ff1f69e05ca6b735265fff771bb95f865c8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 06:40:53 -0300 Subject: [PATCH 23/45] refactor: extract articles url to a shared files --- Bitkit/Constants/WidgetEnv.swift | 2 ++ Bitkit/Services/Widgets/NewsService.swift | 3 +-- BitkitWidget/NewsWidgetService.swift | 4 +--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Bitkit/Constants/WidgetEnv.swift b/Bitkit/Constants/WidgetEnv.swift index 1c7e30320..19eb7e1ae 100644 --- a/Bitkit/Constants/WidgetEnv.swift +++ b/Bitkit/Constants/WidgetEnv.swift @@ -7,4 +7,6 @@ import Foundation /// because it depends on framework types that aren't linked into the widget extension. enum WidgetEnv { static let priceFeedBaseUrl = "https://feeds.synonym.to/price-feed/api" + static let newsFeedBaseUrl = "https://feeds.synonym.to/news-feed/api" + static let newsFeedArticlesUrl = "\(newsFeedBaseUrl)/articles" } diff --git a/Bitkit/Services/Widgets/NewsService.swift b/Bitkit/Services/Widgets/NewsService.swift index 2d6d9aca7..bc1b1db38 100644 --- a/Bitkit/Services/Widgets/NewsService.swift +++ b/Bitkit/Services/Widgets/NewsService.swift @@ -3,7 +3,6 @@ import Foundation /// Service for fetching and caching news articles class NewsService { static let shared = NewsService() - private let baseUrl = "https://feeds.synonym.to/news-feed/api" private let refreshInterval: TimeInterval = 2 * 60 // 2 minutes private init() { @@ -14,7 +13,7 @@ class NewsService { /// - Returns: Array of articles /// - Throws: URLError or decoding error func fetchArticles() async throws -> [Article] { - guard let url = URL(string: "\(baseUrl)/articles") else { + guard let url = URL(string: WidgetEnv.newsFeedArticlesUrl) else { throw URLError(.badURL) } diff --git a/BitkitWidget/NewsWidgetService.swift b/BitkitWidget/NewsWidgetService.swift index f97f3e2b5..c5246b05d 100644 --- a/BitkitWidget/NewsWidgetService.swift +++ b/BitkitWidget/NewsWidgetService.swift @@ -11,14 +11,12 @@ enum NewsWidgetService { case noArticlesAvailable } - private static let baseUrl = "https://feeds.synonym.to/news-feed/api" - static func cachedTopArticles() -> [CachedNewsArticle] { NewsWidgetCache.loadTop() } static func fetchFreshTopArticles() async throws -> [CachedNewsArticle] { - guard let url = URL(string: "\(baseUrl)/articles") else { throw FetchError.invalidURL } + guard let url = URL(string: WidgetEnv.newsFeedArticlesUrl) else { throw FetchError.invalidURL } let (data, _) = try await URLSession.shared.data(from: url) let articles = try JSONDecoder().decode([WireArticle].self, from: data) From 78b4dcd7fdf56d4d9fcd76ebf5ba098b95e51c2e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 07:00:44 -0300 Subject: [PATCH 24/45] feat: open browser on widget click --- Bitkit/MainNavView.swift | 6 ++++++ BitkitWidget/NewsHomeScreenWidget.swift | 1 + 2 files changed, 7 insertions(+) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 3bcb39cd6..ba6c2e3f4 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -246,6 +246,12 @@ struct MainNavView: View { Task { Logger.info("Received deeplink: \(url.absoluteString)") + // Web URLs from widgets (e.g. news article tap) bypass payment handling + if let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { + await UIApplication.shared.open(url) + return + } + if let callback = PubkyRingAuthCallback.parse(url: url) { let handlingResult = await pubkyProfile.handleAuthCallback(callback) diff --git a/BitkitWidget/NewsHomeScreenWidget.swift b/BitkitWidget/NewsHomeScreenWidget.swift index 40bdeb56a..3cb1d0fe8 100644 --- a/BitkitWidget/NewsHomeScreenWidget.swift +++ b/BitkitWidget/NewsHomeScreenWidget.swift @@ -133,6 +133,7 @@ struct NewsHomeScreenWidgetEntryView: View { content } } + .widgetURL(articleURL) .containerBackground(for: .widget) { backgroundView } } From 12fe6e6c299f25af65862bdb0cac9ab196b72a0d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 07:38:55 -0300 Subject: [PATCH 25/45] doc: changelog entry --- changelog.d/next/546.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/546.added.md diff --git a/changelog.d/next/546.added.md b/changelog.d/next/546.added.md new file mode 100644 index 000000000..7d9db63b2 --- /dev/null +++ b/changelog.d/next/546.added.md @@ -0,0 +1 @@ +Added a Bitcoin Headlines home-screen widget and redesigned the in-app Headlines widget, preview, and edit screens to match Figma v61. From 667e544262a634ce6d737c1c0c49fa7b31c6385b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 07:53:44 -0300 Subject: [PATCH 26/45] fix: small and medium sizes displaying different random url --- BitkitWidget/NewsHomeScreenWidget.swift | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/BitkitWidget/NewsHomeScreenWidget.swift b/BitkitWidget/NewsHomeScreenWidget.swift index 3cb1d0fe8..ac787a524 100644 --- a/BitkitWidget/NewsHomeScreenWidget.swift +++ b/BitkitWidget/NewsHomeScreenWidget.swift @@ -15,6 +15,8 @@ struct NewsWidgetEntry: TimelineEntry { // MARK: - Helpers private enum NewsWidgetEntryBuilder { + static let refreshInterval: TimeInterval = 15 * 60 + static func relativeTime(from dateString: String) -> String { let formatter = DateFormatter() formatter.locale = Locale.current @@ -26,6 +28,13 @@ private enum NewsWidgetEntryBuilder { relative.dateTimeStyle = .named return relative.localizedString(for: date, relativeTo: Date()) } + + static func currentArticle(from articles: [CachedNewsArticle], at date: Date = Date()) -> CachedNewsArticle? { + guard !articles.isEmpty else { return nil } + let bucket = Int(date.timeIntervalSince1970 / refreshInterval) + let index = abs(bucket) % articles.count + return articles[index] + } } // MARK: - Timeline Provider @@ -67,7 +76,7 @@ struct NewsWidgetProvider: TimelineProvider { } let cached = NewsWidgetService.cachedTopArticles() - let pick = cached.randomElement() + let pick = NewsWidgetEntryBuilder.currentArticle(from: cached) completion(NewsWidgetEntry( date: Date(), article: pick, @@ -84,7 +93,7 @@ struct NewsWidgetProvider: TimelineProvider { let entry: NewsWidgetEntry do { let fresh = try await NewsWidgetService.fetchFreshTopArticles() - if let pick = fresh.randomElement() { + if let pick = NewsWidgetEntryBuilder.currentArticle(from: fresh) { entry = NewsWidgetEntry( date: Date(), article: pick, @@ -97,7 +106,7 @@ struct NewsWidgetProvider: TimelineProvider { } } catch { let cached = NewsWidgetService.cachedTopArticles() - if let pick = cached.randomElement() { + if let pick = NewsWidgetEntryBuilder.currentArticle(from: cached) { entry = NewsWidgetEntry( date: Date(), article: pick, @@ -110,8 +119,7 @@ struct NewsWidgetProvider: TimelineProvider { } } - let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) - ?? Date().addingTimeInterval(15 * 60) + let nextRefresh = Date().addingTimeInterval(NewsWidgetEntryBuilder.refreshInterval) completion(Timeline(entries: [entry], policy: .after(nextRefresh))) } } From 40b0ad7e74bd5f3fbaeb27372a3b8a23245aab5a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 08:02:29 -0300 Subject: [PATCH 27/45] chore: remove schedule file --- .claude/scheduled_tasks.lock | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index ae98bc612..000000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"53eebf98-950c-4963-804d-ef8de7c14fe4","pid":5145,"procStart":"Wed May 6 16:43:05 2026","acquiredAt":1778087089693} \ No newline at end of file From 2e3f81cb8ff70a55bf435d4da7fb1df8ff41deb5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 08:05:50 -0300 Subject: [PATCH 28/45] fix: replace onAppear with task --- Bitkit/Components/Widgets/NewsWidget.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/Components/Widgets/NewsWidget.swift b/Bitkit/Components/Widgets/NewsWidget.swift index 35295183b..b111e1492 100644 --- a/Bitkit/Components/Widgets/NewsWidget.swift +++ b/Bitkit/Components/Widgets/NewsWidget.swift @@ -1,6 +1,6 @@ import SwiftUI -/// A widget that displays a news article (Figma v61). +/// A widget that displays a news article. struct NewsWidget: View { var options: NewsWidgetOptions = .init() var isEditing: Bool = false @@ -32,7 +32,7 @@ struct NewsWidget: View { } } } - .onAppear { + .task { viewModel.startUpdates() } } From a25a2215d7f6d5fa9e012589462017083cbe267c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 08:57:55 -0300 Subject: [PATCH 29/45] fix: use stable dafe format identifier --- Bitkit/Services/Widgets/NewsService.swift | 2 +- BitkitWidget/NewsHomeScreenWidget.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/Widgets/NewsService.swift b/Bitkit/Services/Widgets/NewsService.swift index bc1b1db38..01ee7a650 100644 --- a/Bitkit/Services/Widgets/NewsService.swift +++ b/Bitkit/Services/Widgets/NewsService.swift @@ -46,7 +46,7 @@ class NewsService { /// - Returns: Human-readable time difference (e.g. "5 hours ago") func timeAgo(from dateString: String) -> String { let formatter = DateFormatter() - formatter.locale = Locale.current + formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" guard let date = formatter.date(from: dateString) else { diff --git a/BitkitWidget/NewsHomeScreenWidget.swift b/BitkitWidget/NewsHomeScreenWidget.swift index ac787a524..4f8887ad5 100644 --- a/BitkitWidget/NewsHomeScreenWidget.swift +++ b/BitkitWidget/NewsHomeScreenWidget.swift @@ -19,7 +19,7 @@ private enum NewsWidgetEntryBuilder { static func relativeTime(from dateString: String) -> String { let formatter = DateFormatter() - formatter.locale = Locale.current + formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" guard let date = formatter.date(from: dateString) else { return "" } From c2e2bd3d6b827118ce9be5363c97c7577f6c163a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 10:33:41 -0300 Subject: [PATCH 30/45] 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 31/45] 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 32/45] 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]) } } From 6e3ab90ddda2937ac9e9296d2d9a017632f10a5a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 13:51:43 -0300 Subject: [PATCH 33/45] fix: reuse existing text component and remove scale factor --- BitkitWidget/PriceHomeScreenWidget.swift | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index 55ab2b7c0..a01033047 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -120,12 +120,12 @@ struct PriceHomeScreenWidgetEntryView: View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 0) { - captionUpText(data.name) + CaptionMText(data.name, textColor: secondaryTextColor) Spacer(minLength: 0) - captionUpText(entry.options.selectedPeriod.rawValue) + CaptionMText(entry.options.selectedPeriod.rawValue, textColor: secondaryTextColor) } - priceText(data.price, size: 22, lineHeight: 26) + priceText(data.price, size: 22) Text(data.change.formatted) .font(Fonts.semiBold(size: 15)) @@ -147,7 +147,7 @@ struct PriceHomeScreenWidgetEntryView: View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 4) { HStack(alignment: .center, spacing: 16) { - captionUpText("\(data.name) \(entry.options.selectedPeriod.rawValue)") + CaptionMText("\(data.name) \(entry.options.selectedPeriod.rawValue)", textColor: secondaryTextColor) .frame(maxWidth: .infinity, alignment: .leading) Text(data.change.formatted) @@ -157,7 +157,7 @@ struct PriceHomeScreenWidgetEntryView: View { .widgetAccentable() } - priceText(data.price, size: 34, lineHeight: 34) + priceText(data.price, size: 34) } Spacer(minLength: 4) @@ -169,19 +169,11 @@ struct PriceHomeScreenWidgetEntryView: View { // MARK: - Sub-views - private func captionUpText(_ text: String) -> Text { - Text(text) - .font(Fonts.medium(size: 13)) - .tracking(1) - .foregroundColor(secondaryTextColor) - } - - private func priceText(_ value: String, size: CGFloat, lineHeight: CGFloat) -> some View { + private func priceText(_ value: String, size: CGFloat) -> some View { Text(value) .font(Fonts.bold(size: size)) .foregroundColor(valueTextColor) .lineLimit(1) - .minimumScaleFactor(0.7) .widgetAccentable() } From b2df71f314a2717f52ce5551ed299adb2e331ce8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 13:58:59 -0300 Subject: [PATCH 34/45] fix: display white32 checkmark for unselected item --- Bitkit/Views/Widgets/WidgetEditItemView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index c21c8ad01..65c6d6c9f 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -33,10 +33,10 @@ struct WidgetEditItemView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - if item.type != .staticItem, item.isChecked { + if item.type != .staticItem { Image("check-mark") .resizable() - .foregroundColor(.brandAccent) + .foregroundColor(item.isChecked ? .brandAccent : .white32) .frame(width: 32, height: 32) } } From 7994460cb14182925f53c476667a03d91ff2a216 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 14:08:25 -0300 Subject: [PATCH 35/45] fix: vertical padding anchored to checkbox image --- Bitkit/Views/Widgets/WidgetEditItemView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index 65c6d6c9f..ce47e12b3 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -21,7 +21,7 @@ struct WidgetEditItemView: View { } private var row: some View { - VStack(spacing: 0) { + VStack(spacing: 8) { HStack(spacing: 16) { item.titleView .frame(maxWidth: .infinity, alignment: .leading) @@ -40,10 +40,11 @@ struct WidgetEditItemView: View { .frame(width: 32, height: 32) } } - .padding(.vertical, 16) + .frame(minHeight: 32) .contentShape(Rectangle()) Divider() } + .padding(.top, 8) } } From 200d2f84f7ea3ab5694bbe0e1d00b3fcac76c66a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 14:18:42 -0300 Subject: [PATCH 36/45] fix: remove the gray bg and custom bg from Navigation bar --- Bitkit/Components/NavigationBar.swift | 19 +++++-------------- .../Widgets/PriceWidgetPreviewView.swift | 3 +-- Bitkit/Views/Widgets/WidgetEditView.swift | 4 +--- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/Bitkit/Components/NavigationBar.swift b/Bitkit/Components/NavigationBar.swift index 787334442..36fb13dbb 100644 --- a/Bitkit/Components/NavigationBar.swift +++ b/Bitkit/Components/NavigationBar.swift @@ -7,7 +7,6 @@ struct NavigationBar: View { let title: String let showBackButton: Bool let showMenuButton: Bool - let showGradient: Bool let action: AnyView? let icon: String? let onBack: (() -> Void)? @@ -16,7 +15,6 @@ struct NavigationBar: View { title: String, showBackButton: Bool = true, showMenuButton: Bool = true, - showGradient: Bool = true, action: AnyView? = nil, icon: String? = nil, onBack: (() -> Void)? = nil @@ -24,7 +22,6 @@ struct NavigationBar: View { self.title = title self.showBackButton = showBackButton self.showMenuButton = showMenuButton - self.showGradient = showGradient self.action = action self.icon = icon self.onBack = onBack @@ -92,17 +89,11 @@ struct NavigationBar: View { } } .frame(height: 48) - .background( - Group { - if showGradient { - LinearGradient( - colors: [.black, .black.opacity(0)], - startPoint: .top, - endPoint: .bottom - ) - } - } - ) + .background(LinearGradient( + colors: [.black, .black.opacity(0)], + startPoint: .top, + endPoint: .bottom + )) .zIndex(.infinity) } } diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index 54d7bca05..57fba9cb4 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -43,7 +43,7 @@ struct PriceWidgetPreviewView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - NavigationBar(title: widgetName, showMenuButton: false, showGradient: false) + NavigationBar(title: widgetName, showMenuButton: false) VStack(alignment: .leading, spacing: 0) { BodyMText(widgetDescription, textColor: .textSecondary) @@ -74,7 +74,6 @@ struct PriceWidgetPreviewView: View { .navigationBarHidden(true) .padding(.horizontal, 16) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray7.ignoresSafeArea()) .task { let options = currentOptions viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index 4c3e81a88..020bbebf7 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -59,8 +59,7 @@ struct WidgetEditView: View { VStack(alignment: .leading, spacing: 0) { NavigationBar( title: id == .price ? widget.name : t("widgets__widget__edit"), - showMenuButton: id != .price, - showGradient: id != .price + showMenuButton: id != .price ) .padding(.bottom, 16) @@ -114,7 +113,6 @@ struct WidgetEditView: View { .navigationBarHidden(true) .padding(.horizontal, 16) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray7.ignoresSafeArea()) .onAppear { if editLogic == nil { let logic = WidgetEditLogic(widgetType: id, widgetsViewModel: widgets) From 870d5e804c0cd411491f096df46ed43a2af0fdf4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 14:24:03 -0300 Subject: [PATCH 37/45] fix: try to fetch real data for preview --- BitkitWidget/PriceHomeScreenWidget.swift | 36 ++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/BitkitWidget/PriceHomeScreenWidget.swift b/BitkitWidget/PriceHomeScreenWidget.swift index a01033047..f30057c11 100644 --- a/BitkitWidget/PriceHomeScreenWidget.swift +++ b/BitkitWidget/PriceHomeScreenWidget.swift @@ -34,23 +34,43 @@ struct PriceWidgetProvider: TimelineProvider { }() func placeholder(in _: Context) -> PriceWidgetEntry { - Self.mockEntry + let options = PriceHomeScreenWidgetOptionsStore.load() + if let cached = PriceWidgetService.cachedPrices(pairs: [options.selectedPair], period: options.selectedPeriod), + !cached.isEmpty + { + return PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: false) + } + return Self.mockEntry } func getSnapshot(in context: Context, completion: @escaping (PriceWidgetEntry) -> Void) { let options = PriceHomeScreenWidgetOptionsStore.load() + let cached = PriceWidgetService.cachedPrices(pairs: [options.selectedPair], period: options.selectedPeriod) ?? [] + + if !cached.isEmpty { + completion(PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: false)) + return + } if context.isPreview { - completion(PriceWidgetEntry( - date: Self.mockEntry.date, - prices: Self.mockEntry.prices, - options: options, - showsError: false - )) + Task { + if let fresh = try? await PriceWidgetService.fetchFreshPrices( + pairs: [options.selectedPair], + period: options.selectedPeriod + ), !fresh.isEmpty { + completion(PriceWidgetEntry(date: Date(), prices: fresh, options: options, showsError: false)) + } else { + completion(PriceWidgetEntry( + date: Self.mockEntry.date, + prices: Self.mockEntry.prices, + options: options, + showsError: false + )) + } + } return } - let cached = PriceWidgetService.cachedPrices(pairs: [options.selectedPair], period: options.selectedPeriod) ?? [] completion(PriceWidgetEntry(date: Date(), prices: cached, options: options, showsError: false)) } From d2f4120ea937b326e46bbf4756ae84d8ec1d0839 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 14:30:24 -0300 Subject: [PATCH 38/45] refactor: make string keys generic to be reused in the furue implementtions --- Bitkit/Resources/Localization/en.lproj/Localizable.strings | 6 +++--- Bitkit/Views/Widgets/PriceWidgetPreviewView.swift | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index afad97a20..4bcd88e86 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1397,9 +1397,9 @@ "widgets__price__period_week" = "Week"; "widgets__price__period_month" = "Month"; "widgets__price__period_year" = "Year"; -"widgets__price__size_small" = "Small"; -"widgets__price__size_wide" = "Wide"; -"widgets__price__widget_settings" = "Widget Settings"; +"widgets__widget__size_small" = "Small"; +"widgets__widget__size_wide" = "Wide"; +"widgets__widget__settings" = "Widget Settings"; "widgets__widget__save_widget" = "Save Widget"; "widgets__news__name" = "Bitcoin Headlines"; "widgets__news__description" = "Read the latest & greatest Bitcoin headlines from various news sites."; diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index 57fba9cb4..ff2ca7b5b 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -96,7 +96,7 @@ struct PriceWidgetPreviewView: View { private var widgetSettingsRow: some View { Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { HStack(alignment: .center, spacing: 0) { - BodyMText(t("widgets__price__widget_settings"), textColor: .textPrimary) + BodyMText(t("widgets__widget__settings"), textColor: .textPrimary) Spacer() @@ -188,8 +188,8 @@ struct PriceWidgetPreviewView: View { Spacer() CaptionMText( carouselPage == 0 - ? t("widgets__price__size_small") - : t("widgets__price__size_wide"), + ? t("widgets__widget__size_small") + : t("widgets__widget__size_wide"), textColor: .textSecondary ) .textCase(.uppercase) From 1c8350ad020146cb2d230e022a141ffabf2a869c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 14:38:39 -0300 Subject: [PATCH 39/45] fix: make prevew frame height adaptable --- Bitkit/Views/Widgets/PriceWidgetPreviewView.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index ff2ca7b5b..ef037893c 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -57,12 +57,8 @@ struct PriceWidgetPreviewView: View { } VStack(spacing: 16) { - Spacer(minLength: 0) - carousel - Spacer(minLength: 0) - sizeLabel pageIndicator @@ -74,6 +70,7 @@ struct PriceWidgetPreviewView: View { .navigationBarHidden(true) .padding(.horizontal, 16) .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() .task { let options = currentOptions viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) @@ -131,7 +128,7 @@ struct PriceWidgetPreviewView: View { .tag(1) } .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(height: 320) + .frame(maxHeight: .infinity) } private var compactPage: some View { From ddfd42b2ab621946e996dba2aa47f4cdab409d50 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 15:01:15 -0300 Subject: [PATCH 40/45] fix: display checkmark for title --- Bitkit/Views/Widgets/WidgetEditLogic.swift | 6 ++++-- Bitkit/Views/Widgets/WidgetEditModels.swift | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index e6b806683..2b603f768 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -37,8 +37,10 @@ class WidgetEditLogic: ObservableObject { // 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 - case .news, .facts: - // Static items (showTitle) are always enabled, so these widgets always have enabled options + case .news: + return newsOptions.showTitle || newsOptions.showSource || newsOptions.showDate + case .facts: + // Facts widget's static title is always shown, so it always has an enabled option return true case .weather: // Weather widget has multiple options, check if any are enabled diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index b7a30e3e6..114bbdf9a 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -313,10 +313,10 @@ enum WidgetEditItemFactory { items.append( WidgetEditItem( key: "showTitle", - type: .staticItem, + type: .toggleItem, titleView: AnyView(TitleText(data.title)), valueView: nil, - isChecked: true // Static items are always shown + isChecked: newsOptions.showTitle ) ) @@ -344,10 +344,10 @@ enum WidgetEditItemFactory { items.append( WidgetEditItem( key: "showTitle", - type: .staticItem, + type: .toggleItem, titleView: AnyView(TitleText("How Bitcoin changed El Salvador in more ways...")), valueView: nil, - isChecked: true // Static items are always shown + isChecked: newsOptions.showTitle ) ) From 88843b67b218d7bd6df6574ad64c3de3ea86daba Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 15:24:38 -0300 Subject: [PATCH 41/45] fix: remove app group fallback --- Bitkit/ViewModels/WidgetsViewModel.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index ee0edad19..8bd9a4d23 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -232,10 +232,6 @@ class WidgetsViewModel: ObservableObject { return options } - if type == .price, let priceOptions = PriceHomeScreenWidgetOptionsStore.load() as? T { - return priceOptions - } - // Return default options if none saved return getDefaultOptions(for: type) as! T } @@ -258,6 +254,10 @@ class WidgetsViewModel: ObservableObject { } persistSavedWidgets() + + if type == .price, let priceOptions = options as? PriceWidgetOptions { + syncPriceOptionsToHomeScreenWidget(priceOptions) + } } catch { print("Failed to save widget options: \(error)") } @@ -305,7 +305,6 @@ class WidgetsViewModel: ObservableObject { savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() } - syncPriceOptionsToHomeScreenWidget() } private func persistSavedWidgets() { @@ -315,12 +314,12 @@ class WidgetsViewModel: ObservableObject { } catch { print("Failed to persist widgets: \(error)") } - syncPriceOptionsToHomeScreenWidget() } - /// Keeps the home-screen WidgetKit price widget in sync with in-app price widget options (App Group). - private func syncPriceOptionsToHomeScreenWidget() { - let options: PriceWidgetOptions = getOptions(for: .price, as: PriceWidgetOptions.self) + /// Mirrors in-app price widget options to the App Group so the home-screen WidgetKit widget can read them. + /// Only invoked when the user explicitly changes price widget options — adding, deleting, or resetting + /// in-app widgets must not affect the independent OS home-screen widget. + private func syncPriceOptionsToHomeScreenWidget(_ options: PriceWidgetOptions) { PriceHomeScreenWidgetOptionsStore.save(options) PriceHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } From a2e0e7b770f7205e4c34a71087038577d937e1ec Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 15 May 2026 10:12:22 +0200 Subject: [PATCH 42/45] WIP --- Bitkit/Models/BlocksWidgetOptions.swift | 6 +++--- Bitkit/Views/Widgets/WidgetEditLogic.swift | 23 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Bitkit/Models/BlocksWidgetOptions.swift b/Bitkit/Models/BlocksWidgetOptions.swift index 1054be97e..ccccf077c 100644 --- a/Bitkit/Models/BlocksWidgetOptions.swift +++ b/Bitkit/Models/BlocksWidgetOptions.swift @@ -9,7 +9,7 @@ struct BlocksWidgetOptions: Codable, Equatable { var height: Bool = true var time: Bool = true var date: Bool = true - var transactionCount: Bool = false + var transactionCount: Bool = true var size: Bool = false var fees: Bool = false var showSource: Bool = false @@ -18,7 +18,7 @@ struct BlocksWidgetOptions: Codable, Equatable { height: Bool = true, time: Bool = true, date: Bool = true, - transactionCount: Bool = false, + transactionCount: Bool = true, size: Bool = false, fees: Bool = false, showSource: Bool = false @@ -47,7 +47,7 @@ struct BlocksWidgetOptions: Codable, Equatable { 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 + transactionCount = try container.decodeIfPresent(Bool.self, forKey: .transactionCount) ?? true 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/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index 91661143f..ed805b4c6 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -89,18 +89,25 @@ class WidgetEditLogic: ObservableObject { case .blocks: switch item.key { case "height": + guard canToggleBlockOption(blocksOptions.height) else { break } blocksOptions.height.toggle() case "time": + guard canToggleBlockOption(blocksOptions.time) else { break } blocksOptions.time.toggle() case "date": + guard canToggleBlockOption(blocksOptions.date) else { break } blocksOptions.date.toggle() case "transactionCount": + guard canToggleBlockOption(blocksOptions.transactionCount) else { break } blocksOptions.transactionCount.toggle() case "size": + guard canToggleBlockOption(blocksOptions.size) else { break } blocksOptions.size.toggle() case "fees": + guard canToggleBlockOption(blocksOptions.fees) else { break } blocksOptions.fees.toggle() case "showSource": + guard canToggleBlockOption(blocksOptions.showSource) else { break } blocksOptions.showSource.toggle() default: break @@ -161,6 +168,22 @@ class WidgetEditLogic: ObservableObject { priceOptions.selectedPair = pairName } + private func canToggleBlockOption(_ isCurrentlyEnabled: Bool) -> Bool { + isCurrentlyEnabled || enabledBlockOptionsCount < 4 + } + + private var enabledBlockOptionsCount: Int { + [ + blocksOptions.height, + blocksOptions.time, + blocksOptions.date, + blocksOptions.transactionCount, + blocksOptions.size, + blocksOptions.fees, + blocksOptions.showSource, + ].filter { $0 }.count + } + func loadCurrentOptions() { switch widgetType { case .blocks: From f8f4f6f9a43270a6c581824357bad11caad23374 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 15 May 2026 12:34:32 +0200 Subject: [PATCH 43/45] fixes --- .../Views/Widgets/BlocksWidgetPreviewView.swift | 2 +- Bitkit/Views/Widgets/WidgetEditLogic.swift | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift b/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift index 710354853..55796e081 100644 --- a/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift @@ -186,7 +186,7 @@ struct BlocksWidgetPreviewView: View { Spacer() ForEach(0 ..< 2, id: \.self) { index in Circle() - .fill(carouselPage == index ? Color.brandAccent : Color.white.opacity(0.32)) + .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) .frame(width: 8, height: 8) } Spacer() diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index fbbb0db61..410c553f5 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -164,6 +164,22 @@ class WidgetEditLogic: ObservableObject { onStateChange?() } + private func canToggleBlockOption(_ isCurrentlyEnabled: Bool) -> Bool { + isCurrentlyEnabled || enabledBlockOptionsCount < 4 + } + + private var enabledBlockOptionsCount: Int { + [ + blocksOptions.height, + blocksOptions.time, + blocksOptions.date, + blocksOptions.transactionCount, + blocksOptions.size, + blocksOptions.fees, + blocksOptions.showSource, + ].filter { $0 }.count + } + func loadCurrentOptions() { switch widgetType { case .blocks: From 90e68bc14896779e729cf6081d98c66a8f46d8d3 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 15 May 2026 15:18:28 +0200 Subject: [PATCH 44/45] fixes --- Bitkit/Components/Widgets/BlocksWidget.swift | 16 ++------- Bitkit/Models/BlocksWidgetFields.swift | 22 ++----------- Bitkit/Models/BlocksWidgetOptions.swift | 33 +++++++++++++------ .../Localization/en.lproj/Localizable.strings | 4 +-- Bitkit/Services/MigrationsService.swift | 24 ++------------ Bitkit/Utilities/WidgetsBackupConverter.swift | 8 ++--- Bitkit/Views/Widgets/WidgetEditLogic.swift | 5 --- Bitkit/Views/Widgets/WidgetEditModels.swift | 3 +- BitkitWidget/BlocksHomeScreenWidget.swift | 14 +++----- 9 files changed, 41 insertions(+), 88 deletions(-) diff --git a/Bitkit/Components/Widgets/BlocksWidget.swift b/Bitkit/Components/Widgets/BlocksWidget.swift index 94b29626c..3344d0ae5 100644 --- a/Bitkit/Components/Widgets/BlocksWidget.swift +++ b/Bitkit/Components/Widgets/BlocksWidget.swift @@ -1,17 +1,5 @@ import SwiftUI -// 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 - } -} - // MARK: - Widget /// In-app Bitcoin Blocks widget (v61). Renders the wide layout — used inside the home feed @@ -86,7 +74,7 @@ private struct BlocksWidgetWideRow: View { .foregroundColor(.brandAccent) .frame(width: 20, height: 20) - BodyMText(field.inAppLabel, textColor: .white80) + BodyMText(field.label, textColor: .white80) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) @@ -105,7 +93,7 @@ struct BlocksWidgetCompactContent: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - ForEach(options.compactFields, id: \.self) { field in + ForEach(options.enabledFields, id: \.self) { field in HStack(alignment: .center, spacing: 8) { Image(field.iconName) .resizable() diff --git a/Bitkit/Models/BlocksWidgetFields.swift b/Bitkit/Models/BlocksWidgetFields.swift index 8c09b0465..c8dfa75b8 100644 --- a/Bitkit/Models/BlocksWidgetFields.swift +++ b/Bitkit/Models/BlocksWidgetFields.swift @@ -1,7 +1,6 @@ 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. +/// Ordered field set used by the v61 Blocks widget. /// /// 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 @@ -13,12 +12,6 @@ enum BlocksWidgetField: String, CaseIterable { 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 { @@ -28,7 +21,6 @@ enum BlocksWidgetField: String, CaseIterable { case .transactionCount: return "Transactions" case .size: return "Size" case .fees: return "Fees" - case .showSource: return "Source" } } @@ -41,7 +33,6 @@ enum BlocksWidgetField: String, CaseIterable { case .transactionCount: return "arrow-up-down" case .size: return "file-text" case .fees: return "coins" - case .showSource: return "globe" } } @@ -53,7 +44,6 @@ enum BlocksWidgetField: String, CaseIterable { case .transactionCount: return options.transactionCount case .size: return options.size case .fees: return options.fees - case .showSource: return options.showSource } } @@ -65,21 +55,13 @@ enum BlocksWidgetField: String, CaseIterable { 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. + /// All enabled fields in declared order. 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 index ccccf077c..0d6838d6d 100644 --- a/Bitkit/Models/BlocksWidgetOptions.swift +++ b/Bitkit/Models/BlocksWidgetOptions.swift @@ -1,10 +1,6 @@ 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 @@ -12,7 +8,6 @@ struct BlocksWidgetOptions: Codable, Equatable { var transactionCount: Bool = true var size: Bool = false var fees: Bool = false - var showSource: Bool = false init( height: Bool = true, @@ -20,8 +15,7 @@ struct BlocksWidgetOptions: Codable, Equatable { date: Bool = true, transactionCount: Bool = true, size: Bool = false, - fees: Bool = false, - showSource: Bool = false + fees: Bool = false ) { self.height = height self.time = time @@ -29,7 +23,7 @@ struct BlocksWidgetOptions: Codable, Equatable { self.transactionCount = transactionCount self.size = size self.fees = fees - self.showSource = showSource + limitEnabledFields() } private enum CodingKeys: String, CodingKey { @@ -39,7 +33,6 @@ struct BlocksWidgetOptions: Codable, Equatable { case transactionCount case size case fees - case showSource } init(from decoder: Decoder) throws { @@ -50,6 +43,26 @@ struct BlocksWidgetOptions: Codable, Equatable { transactionCount = try container.decodeIfPresent(Bool.self, forKey: .transactionCount) ?? true 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 + limitEnabledFields() + } + + private mutating func limitEnabledFields() { + let fields: [WritableKeyPath] = [ + \.height, + \.time, + \.date, + \.transactionCount, + \.size, + \.fees, + ] + + var enabledCount = 0 + for field in fields where self[keyPath: field] { + if enabledCount < 4 { + enabledCount += 1 + } else { + self[keyPath: field] = false + } + } } } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index eecf264a2..a74556768 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1405,9 +1405,9 @@ "widgets__news__error" = "Couldn\'t get the latest news"; "widgets__news__content_header" = "Content"; "widgets__blocks__name" = "Bitcoin Blocks"; -"widgets__blocks__description" = "Examine various statistics on newly mined Bitcoin Blocks."; +"widgets__blocks__description" = "Examine various statistics on newly mined Bitcoin Blocks. Powered by mempool.space."; "widgets__blocks__error" = "Couldn\'t get blocks data"; -"widgets__blocks__data_header" = "Data"; +"widgets__blocks__data_header" = "Data (max 4)"; "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/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index b3d3c5f3a..43a0a8370 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -270,19 +270,6 @@ private struct MigrationNewsWidgetOptions: Codable { var showSource: Bool } -private struct MigrationBlocksWidgetOptions: Codable { - var height: Bool - var time: Bool - var date: Bool - var transactionCount: Bool - var size: Bool - var weight: Bool - var difficulty: Bool - var hash: Bool - var merkleRoot: Bool - var showSource: Bool -} - private struct MigrationFactsWidgetOptions: Codable { var showSource: Bool } @@ -1962,17 +1949,12 @@ extension MigrationsService { let blocksPrefs = (widgetsDict["blocksPreferences"] as? [String: Any]) ?? (widgetsDict["blocks"] as? [String: Any]) if let prefs = blocksPrefs { - let options = MigrationBlocksWidgetOptions( + let options = BlocksWidgetOptions( height: getBool(from: prefs, key: "height", fallbackKey: "showBlock", defaultValue: true), time: getBool(from: prefs, key: "time", fallbackKey: "showTime", defaultValue: true), date: getBool(from: prefs, key: "date", fallbackKey: "showDate", defaultValue: true), - transactionCount: getBool(from: prefs, key: "transactionCount", fallbackKey: "showTransactions", defaultValue: false), - size: getBool(from: prefs, key: "size", fallbackKey: "showSize", defaultValue: false), - weight: getBool(from: prefs, key: "weight", defaultValue: false), - difficulty: getBool(from: prefs, key: "difficulty", defaultValue: false), - hash: getBool(from: prefs, key: "hash", defaultValue: false), - merkleRoot: getBool(from: prefs, key: "merkleRoot", defaultValue: false), - showSource: getBool(from: prefs, key: "showSource", defaultValue: false) + transactionCount: getBool(from: prefs, key: "transactionCount", fallbackKey: "showTransactions", defaultValue: true), + size: getBool(from: prefs, key: "size", fallbackKey: "showSize", defaultValue: false) ) if let data = try? JSONEncoder().encode(options) { result["blocks"] = data diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 32fe891da..7783d31f8 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -32,7 +32,7 @@ enum WidgetsBackupConverter { "showDate": options.date, "showTransactions": options.transactionCount, "showSize": options.size, - "showSource": options.showSource, + "showFees": options.fees, ] } case .news: @@ -125,10 +125,9 @@ enum WidgetsBackupConverter { height: prefs["showBlock"] as? Bool ?? true, time: prefs["showTime"] as? Bool ?? true, date: prefs["showDate"] as? Bool ?? true, - transactionCount: prefs["showTransactions"] as? Bool ?? false, + transactionCount: prefs["showTransactions"] as? Bool ?? true, size: prefs["showSize"] as? Bool ?? false, - fees: prefs["showFees"] as? Bool ?? false, - showSource: prefs["showSource"] as? Bool ?? false + fees: prefs["showFees"] as? Bool ?? false ) optionsData = try? JSONEncoder().encode(iosOptions) } @@ -199,7 +198,6 @@ enum WidgetsBackupConverter { "showTransactions": defaults.transactionCount, "showSize": defaults.size, "showFees": defaults.fees, - "showSource": defaults.showSource, ] } diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index 410c553f5..b530ff4f0 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -40,7 +40,6 @@ class WidgetEditLogic: ObservableObject { || blocksOptions.transactionCount || blocksOptions.size || blocksOptions.fees - || blocksOptions.showSource case .news: return newsOptions.showTitle || newsOptions.showSource || newsOptions.showDate case .facts: @@ -106,9 +105,6 @@ class WidgetEditLogic: ObservableObject { case "fees": guard canToggleBlockOption(blocksOptions.fees) else { break } blocksOptions.fees.toggle() - case "showSource": - guard canToggleBlockOption(blocksOptions.showSource) else { break } - blocksOptions.showSource.toggle() default: break } @@ -176,7 +172,6 @@ class WidgetEditLogic: ObservableObject { blocksOptions.transactionCount, blocksOptions.size, blocksOptions.fees, - blocksOptions.showSource, ].filter { $0 }.count } diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 65e664c6a..5a347f9c1 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -82,7 +82,6 @@ enum WidgetEditItemFactory { 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] ?? "" }() @@ -94,7 +93,7 @@ enum WidgetEditItemFactory { .renderingMode(.template) .foregroundColor(.brandAccent) .frame(width: 20, height: 20) - BodySSBText(field.inAppLabel, textColor: .textSecondary) + BodySSBText(field.label, textColor: .textSecondary) } ) items.append( diff --git a/BitkitWidget/BlocksHomeScreenWidget.swift b/BitkitWidget/BlocksHomeScreenWidget.swift index 17ab544da..6f3c348a9 100644 --- a/BitkitWidget/BlocksHomeScreenWidget.swift +++ b/BitkitWidget/BlocksHomeScreenWidget.swift @@ -107,7 +107,7 @@ struct BlocksHomeScreenWidgetEntryView: View { case .systemSmall: compactLayout(block: block) default: - wideLayout(block: block, fields: entry.options.compactFields) + wideLayout(block: block, fields: entry.options.enabledFields) } } else { ProgressView() @@ -117,11 +117,10 @@ struct BlocksHomeScreenWidgetEntryView: View { // MARK: - Layouts - /// Compact (`.systemSmall`): icon + value rows, capped at 4. Default-selected fields - /// take priority; remaining slots are filled by extras in declared order. + /// Compact (`.systemSmall`): icon + value rows for the selected fields. private func compactLayout(block: CachedBlock) -> some View { VStack(alignment: .leading, spacing: 16) { - ForEach(entry.options.compactFields, id: \.self) { field in + ForEach(entry.options.enabledFields, id: \.self) { field in HStack(alignment: .center, spacing: 8) { iconImage(field: field) Text(field.value(from: block)) @@ -132,15 +131,13 @@ struct BlocksHomeScreenWidgetEntryView: View { .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. + /// Wide layout (`.systemMedium`): icon + label + value rows for the selected fields. private func wideLayout(block: CachedBlock, fields: [BlocksWidgetField]) -> some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 16) { ForEach(fields, id: \.self) { field in HStack(alignment: .center, spacing: 8) { iconImage(field: field) @@ -157,7 +154,6 @@ struct BlocksHomeScreenWidgetEntryView: View { .widgetAccentable() } } - Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } From 33431a026a727c45e6055b0a05aaaa750ec46178 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 15 May 2026 16:02:58 +0200 Subject: [PATCH 45/45] fixes --- Bitkit.xcodeproj/project.pbxproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 6c3746859..703b0c05a 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -108,6 +108,7 @@ "Extensions/LDKNode+AddressType.swift", Extensions/PaymentDetails.swift, Models/BlocktankNotificationType.swift, + Models/BlocksWidgetOptions.swift, Models/LnPeer.swift, Models/PubkyPublicKeyFormat.swift, Models/Toast.swift,