From d60c84cc7299d43f1bf3fd40ab376c365a07801f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 06:33:06 -0300 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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 08/11] 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 09/11] 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 ddfd42b2ab621946e996dba2aa47f4cdab409d50 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 7 May 2026 15:01:15 -0300 Subject: [PATCH 10/11] 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 0d98ff55f12ff10ed9fa233b5cab1b30e70f9c19 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 8 May 2026 11:44:09 +0200 Subject: [PATCH 11/11] 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