From 61db06c5d6ec78beda55d872fedfd4234cac9e1b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 6 May 2026 07:57:56 -0300 Subject: [PATCH 01/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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 6e3ab90ddda2937ac9e9296d2d9a017632f10a5a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 13:51:43 -0300 Subject: [PATCH 30/43] 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 31/43] 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 32/43] 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 33/43] 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 34/43] 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 35/43] 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 36/43] 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 37/43] 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 38/43] 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 9b221d001d2e272887119b085d07df16398ef106 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 8 May 2026 11:44:09 +0200 Subject: [PATCH 39/43] test: widget test ids adjustment --- Bitkit/Components/Widgets/PriceWidget.swift | 8 ++++++++ Bitkit/Views/Widgets/WidgetEditView.swift | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index 0d1323770..eb75c65fc 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -74,7 +74,9 @@ struct PriceWidgetWideContent: View { textColor: data.change.isPositive ? .greenAccent : .redAccent ) .lineLimit(1) + .accessibilityIdentifier("price_card_pair_change_\(data.name)") } + .accessibilityIdentifier("PriceWidgetRow-\(data.name)") Text(data.price) .font(Fonts.bold(size: 34)) @@ -82,10 +84,12 @@ struct PriceWidgetWideContent: View { .lineLimit(1) .minimumScaleFactor(0.7) .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("price_card_pair_price_\(data.name)") } PriceChart(values: data.pastValues, isPositive: data.change.isPositive) .frame(height: 48) + .accessibilityIdentifier("price_card_chart") } .frame(maxWidth: .infinity, alignment: .leading) } @@ -107,22 +111,26 @@ struct PriceWidgetCompactContent: View { CaptionMText(period.rawValue, textColor: .textSecondary) .textCase(.uppercase) } + .accessibilityIdentifier("price_card_small_pair_row_\(data.name)") Text(data.price) .font(Fonts.bold(size: 22)) .foregroundColor(.textPrimary) .lineLimit(1) .minimumScaleFactor(0.7) + .accessibilityIdentifier("price_card_small_pair_price_\(data.name)") BodySSBText( data.change.formatted, textColor: data.change.isPositive ? .greenAccent : .redAccent ) .lineLimit(1) + .accessibilityIdentifier("price_card_small_pair_change_\(data.name)") } PriceChart(values: data.pastValues, isPositive: data.change.isPositive) .frame(height: 64) + .accessibilityIdentifier("price_card_small_chart") } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index 020bbebf7..09152b67e 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -78,7 +78,7 @@ struct WidgetEditView: View { item: item, onToggle: { editLogic?.toggleOption(item) } ) - .accessibilityIdentifier("WidgetEditField-\(item.key)") + .accessibilityIdentifier("\(item.key)_setting_row") } } .id(refreshTrigger) // Force refresh when refreshTrigger changes From 0d98ff55f12ff10ed9fa233b5cab1b30e70f9c19 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 8 May 2026 11:44:09 +0200 Subject: [PATCH 40/43] test: widget test ids adjustment --- Bitkit/Components/Widgets/PriceWidget.swift | 8 ++++++++ Bitkit/Views/Widgets/WidgetEditView.swift | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index 0d1323770..eb75c65fc 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -74,7 +74,9 @@ struct PriceWidgetWideContent: View { textColor: data.change.isPositive ? .greenAccent : .redAccent ) .lineLimit(1) + .accessibilityIdentifier("price_card_pair_change_\(data.name)") } + .accessibilityIdentifier("PriceWidgetRow-\(data.name)") Text(data.price) .font(Fonts.bold(size: 34)) @@ -82,10 +84,12 @@ struct PriceWidgetWideContent: View { .lineLimit(1) .minimumScaleFactor(0.7) .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("price_card_pair_price_\(data.name)") } PriceChart(values: data.pastValues, isPositive: data.change.isPositive) .frame(height: 48) + .accessibilityIdentifier("price_card_chart") } .frame(maxWidth: .infinity, alignment: .leading) } @@ -107,22 +111,26 @@ struct PriceWidgetCompactContent: View { CaptionMText(period.rawValue, textColor: .textSecondary) .textCase(.uppercase) } + .accessibilityIdentifier("price_card_small_pair_row_\(data.name)") Text(data.price) .font(Fonts.bold(size: 22)) .foregroundColor(.textPrimary) .lineLimit(1) .minimumScaleFactor(0.7) + .accessibilityIdentifier("price_card_small_pair_price_\(data.name)") BodySSBText( data.change.formatted, textColor: data.change.isPositive ? .greenAccent : .redAccent ) .lineLimit(1) + .accessibilityIdentifier("price_card_small_pair_change_\(data.name)") } PriceChart(values: data.pastValues, isPositive: data.change.isPositive) .frame(height: 64) + .accessibilityIdentifier("price_card_small_chart") } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index 020bbebf7..09152b67e 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -78,7 +78,7 @@ struct WidgetEditView: View { item: item, onToggle: { editLogic?.toggleOption(item) } ) - .accessibilityIdentifier("WidgetEditField-\(item.key)") + .accessibilityIdentifier("\(item.key)_setting_row") } } .id(refreshTrigger) // Force refresh when refreshTrigger changes From c1c5929ba96dad442a3212975a0060dc3b5adf43 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Thu, 14 May 2026 16:10:48 +0200 Subject: [PATCH 41/43] fixes --- Bitkit/ViewModels/WidgetsViewModel.swift | 7 ++-- Bitkit/Views/Widgets/WidgetEditItemView.swift | 34 ++++++++++++++----- Bitkit/Views/Widgets/WidgetEditLogic.swift | 2 +- Bitkit/Views/Widgets/WidgetEditModels.swift | 5 +-- BitkitTests/WidgetsViewModelTests.swift | 32 +++++++++++++++++ 5 files changed, 66 insertions(+), 14 deletions(-) create mode 100644 BitkitTests/WidgetsViewModelTests.swift diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 8bd9a4d23..d42550ee2 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -188,9 +188,10 @@ class WidgetsViewModel: ObservableObject { // Don't add duplicates guard !isWidgetSaved(type) else { return } - let newSavedWidget = SavedWidget(type: type) - savedWidgetsWithOptions.append(newSavedWidget) - savedWidgets.append(newSavedWidget.toWidget()) + if !savedWidgetsWithOptions.contains(where: { $0.type == type }) { + savedWidgetsWithOptions.append(SavedWidget(type: type)) + } + savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() } diff --git a/Bitkit/Views/Widgets/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index ce47e12b3..8aad26a02 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -12,7 +12,7 @@ struct WidgetEditItemView: View { .padding(.vertical, 16) case .staticItem: row - case .toggleItem: + case .toggleItem, .radioItem: Button(action: onToggle) { row } @@ -33,18 +33,36 @@ struct WidgetEditItemView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - if item.type != .staticItem { - Image("check-mark") - .resizable() - .foregroundColor(item.isChecked ? .brandAccent : .white32) - .frame(width: 32, height: 32) - } + accessoryView } .frame(minHeight: 32) .contentShape(Rectangle()) - Divider() + CustomDivider() } .padding(.top, 8) } + + @ViewBuilder + private var accessoryView: some View { + switch item.type { + case .toggleItem: + Image("check-mark") + .resizable() + .foregroundColor(item.isChecked ? .brandAccent : .gray3) + .frame(width: 32, height: 32) + case .radioItem: + if item.isChecked { + Image("check-mark") + .resizable() + .foregroundColor(.brandAccent) + .frame(width: 32, height: 32) + } else { + Color.clear + .frame(width: 32, height: 32) + } + case .staticItem, .sectionHeader: + EmptyView() + } + } } diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index e6b806683..1e5478102 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -77,7 +77,7 @@ class WidgetEditLogic: ObservableObject { func toggleOption(_ item: WidgetEditItem) { // Don't toggle static items - guard item.type == .toggleItem else { return } + guard item.type != .staticItem else { return } switch widgetType { case .blocks: diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 76ff00a6b..9757a5ad9 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -19,6 +19,7 @@ extension GraphPeriod { enum WidgetItemType { case toggleItem + case radioItem case staticItem /// Non-tappable section header (uppercase caption above a group of items). case sectionHeader @@ -386,7 +387,7 @@ enum WidgetEditItemFactory { items.append( WidgetEditItem( key: pair, - type: .toggleItem, + type: .radioItem, titleView: AnyView( BodySSBText(pair, textColor: isSelected ? .textPrimary : .textSecondary) ), @@ -403,7 +404,7 @@ enum WidgetEditItemFactory { items.append( WidgetEditItem( key: period.rawValue, - type: .toggleItem, + type: .radioItem, titleView: AnyView( BodySSBText(period.editScreenLabel, textColor: isSelected ? .textPrimary : .textSecondary) ), diff --git a/BitkitTests/WidgetsViewModelTests.swift b/BitkitTests/WidgetsViewModelTests.swift new file mode 100644 index 000000000..6f97925de --- /dev/null +++ b/BitkitTests/WidgetsViewModelTests.swift @@ -0,0 +1,32 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class WidgetsViewModelTests: XCTestCase { + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: "savedWidgets") + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: "savedWidgets") + super.tearDown() + } + + func testSavingWidgetAfterEditingUnsavedOptionsDoesNotDuplicateAfterReload() { + let widgets = WidgetsViewModel() + widgets.deleteWidget(.suggestions) + widgets.deleteWidget(.price) + widgets.deleteWidget(.blocks) + + widgets.saveOptions(PriceWidgetOptions(selectedPair: "BTC/EUR", selectedPeriod: .oneWeek), for: .price) + widgets.saveWidget(.price) + + let reloadedWidgets = WidgetsViewModel() + let priceWidgets = reloadedWidgets.savedWidgets.filter { $0.type == .price } + let options: PriceWidgetOptions = reloadedWidgets.getOptions(for: .price, as: PriceWidgetOptions.self) + + XCTAssertEqual(priceWidgets.count, 1) + XCTAssertEqual(options, PriceWidgetOptions(selectedPair: "BTC/EUR", selectedPeriod: .oneWeek)) + } +} From f957422649df6ce970f1adbd937e1b4d317e1718 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Thu, 14 May 2026 17:49:42 +0200 Subject: [PATCH 42/43] fixes --- Bitkit/Views/Widgets/NewsWidgetPreviewView.swift | 2 +- Bitkit/Views/Widgets/PriceWidgetPreviewView.swift | 2 +- Bitkit/Views/Widgets/WidgetEditItemView.swift | 4 ++-- Bitkit/Views/Widgets/WidgetEditLogic.swift | 6 +----- Bitkit/Views/Widgets/WidgetEditModels.swift | 2 +- Bitkit/Views/Widgets/WidgetEditView.swift | 9 +++------ 6 files changed, 9 insertions(+), 16 deletions(-) diff --git a/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift b/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift index 838c386bd..ad05b9686 100644 --- a/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift @@ -189,7 +189,7 @@ struct NewsWidgetPreviewView: 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/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift index ef037893c..1b95fdc66 100644 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift @@ -199,7 +199,7 @@ struct PriceWidgetPreviewView: 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/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index 8aad26a02..1d692fc86 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -46,7 +46,7 @@ struct WidgetEditItemView: View { @ViewBuilder private var accessoryView: some View { switch item.type { - case .toggleItem: + case .staticItem, .toggleItem: Image("check-mark") .resizable() .foregroundColor(item.isChecked ? .brandAccent : .gray3) @@ -61,7 +61,7 @@ struct WidgetEditItemView: View { Color.clear .frame(width: 32, height: 32) } - case .staticItem, .sectionHeader: + case .sectionHeader: EmptyView() } } diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index b6474ccaa..caf1216a2 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -141,7 +141,7 @@ class WidgetEditLogic: ObservableObject { case .price: switch item.key { case "BTC/USD", "BTC/EUR", "BTC/GBP", "BTC/JPY": - selectTradingPair(item.key) + priceOptions.selectedPair = item.key case "1D": priceOptions.selectedPeriod = .oneDay case "1W": @@ -159,10 +159,6 @@ class WidgetEditLogic: ObservableObject { onStateChange?() } - private func selectTradingPair(_ pairName: String) { - priceOptions.selectedPair = pairName - } - func loadCurrentOptions() { switch widgetType { case .blocks: diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 6df0c8d52..af7d61822 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -314,7 +314,7 @@ enum WidgetEditItemFactory { items.append( WidgetEditItem( key: "showTitle", - type: .toggleItem, + type: .staticItem, titleView: AnyView(TitleText(data.title)), valueView: nil, isChecked: newsOptions.showTitle diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index 09152b67e..eaa2ef158 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -63,12 +63,9 @@ struct WidgetEditView: View { ) .padding(.bottom, 16) - if id != .price { - BodyMText( - t("widgets__widget__edit_description", variables: ["name": widget.name]), - textColor: .textSecondary - ) - .padding(.bottom, 16) + if id != .price, id != .news { + BodyMText(t("widgets__widget__edit_description", variables: ["name": widget.name])) + .padding(.bottom, 16) } ScrollView(showsIndicators: false) { From 7d637b69f297438ed2d98679552a4a8f4026d083 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Thu, 14 May 2026 18:03:03 +0200 Subject: [PATCH 43/43] fixes --- Bitkit/Resources/Localization/en.lproj/Localizable.strings | 1 - Bitkit/Views/Widgets/WidgetEditLogic.swift | 6 ++---- Bitkit/Views/Widgets/WidgetEditModels.swift | 2 +- Bitkit/Views/Widgets/WidgetEditView.swift | 5 ----- 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 951a56c0e..842a2462b 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1382,7 +1382,6 @@ "widgets__widget__edit" = "Widget Feed"; "widgets__widget__edit_default" = "Default"; "widgets__widget__edit_custom" = "Custom"; -"widgets__widget__edit_description" = "Please select which fields you want to display in the {name} widget."; "widgets__widget__source" = "Source"; "widgets__add" = "Add Widget"; "widgets__list__button" = "Enable In Settings"; diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index caf1216a2..1b135d91a 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -37,10 +37,8 @@ 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: - return newsOptions.showTitle || newsOptions.showSource || newsOptions.showDate - case .facts: - // Facts widget's static title is always shown, so it always has an enabled option + case .news, .facts: + // Static items (showTitle) are always enabled, so these widgets always have enabled options 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 af7d61822..74852fd92 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -317,7 +317,7 @@ enum WidgetEditItemFactory { type: .staticItem, titleView: AnyView(TitleText(data.title)), valueView: nil, - isChecked: newsOptions.showTitle + isChecked: true // Static items are always shown ) ) diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index eaa2ef158..67a5094c2 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -63,11 +63,6 @@ struct WidgetEditView: View { ) .padding(.bottom, 16) - if id != .price, id != .news { - BodyMText(t("widgets__widget__edit_description", variables: ["name": widget.name])) - .padding(.bottom, 16) - } - ScrollView(showsIndicators: false) { LazyVStack(spacing: 0) { ForEach(getItems(), id: \.key) { item in