diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 799da445..15e22deb 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 1a64e748..b111e149 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. 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) } + } + } + .task { + viewModel.startUpdates() + } + } - if options.showTitle { - TitleText(data.title) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - } + @ViewBuilder + private var content: some View { + if viewModel.isLoading && viewModel.widgetData == nil { + WidgetContentBuilder.loadingView() + } else if viewModel.error != nil { + WidgetContentBuilder.errorView(t("widgets__news__error")) + } else if let data = viewModel.widgetData { + NewsWidgetWideContent(data: data, options: options) + } + } +} + +// MARK: - Wide layout (in-app + 343-wide carousel page) + +struct NewsWidgetWideContent: View { + let data: WidgetData + let options: NewsWidgetOptions + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if options.showTitle { + TitleText(data.title) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .leading) + } + if options.showSource || options.showDate { + HStack(alignment: .center, spacing: 8) { if options.showSource { - WidgetContentBuilder.sourceRow(source: data.publisher) + BodySSBText(data.publisher, textColor: .brandAccent) + .lineLimit(1) + } + Spacer(minLength: 0) + if options.showDate { + BodySSBText(data.timeAgo, textColor: .textSecondary) + .lineLimit(1) } } + .frame(maxWidth: .infinity) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Compact layout (small carousel preview + 163×192 OS widget) + +struct NewsWidgetCompactContent: View { + let data: WidgetData + let options: NewsWidgetOptions + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if options.showTitle { + TitleText(data.title) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .leading) } - .contentShape(Rectangle()) - .onTapGesture { - if !isEditing, let data = viewModel.widgetData, let url = URL(string: data.link) { - UIApplication.shared.open(url) + + Spacer(minLength: 8) + + if options.showDate { + HStack { + Spacer(minLength: 0) + BodySSBText(data.timeAgo, textColor: .textSecondary) + .lineLimit(1) } } } - .onAppear { - viewModel.startUpdates() - } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.gray6) + .cornerRadius(16) } } diff --git a/Bitkit/Constants/WidgetEnv.swift b/Bitkit/Constants/WidgetEnv.swift index 1c7e3032..19eb7e1a 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/MainNavView.swift b/Bitkit/MainNavView.swift index 2a17eaa7..ba6c2e3f 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) @@ -432,9 +438,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 00000000..1db97013 --- /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 00000000..4497b6b5 --- /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 4bcd88e8..951a56c0 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1404,6 +1404,7 @@ "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__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 00000000..043d161d --- /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 e02681ab..01ee7a65 100644 --- a/Bitkit/Services/Widgets/NewsService.swift +++ b/Bitkit/Services/Widgets/NewsService.swift @@ -3,24 +3,22 @@ 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 /// - 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) } 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 +27,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 @@ -69,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 { @@ -83,60 +60,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 +178,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 8bd9a4d2..d921ab7e 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -258,6 +258,10 @@ class WidgetsViewModel: ObservableObject { if type == .price, let priceOptions = options as? PriceWidgetOptions { syncPriceOptionsToHomeScreenWidget(priceOptions) } + + if type == .news, let newsOptions = options as? NewsWidgetOptions { + syncNewsOptionsToHomeScreenWidget(newsOptions) + } } catch { print("Failed to save widget options: \(error)") } @@ -323,4 +327,12 @@ class WidgetsViewModel: ObservableObject { PriceHomeScreenWidgetOptionsStore.save(options) PriceHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } + + /// Mirrors in-app news widget options to the App Group so the home-screen WidgetKit widget can read them. + /// Only invoked when the user explicitly changes news widget options — adding, deleting, or resetting + /// in-app widgets must not affect the independent OS home-screen widget. + private func syncNewsOptionsToHomeScreenWidget(_ options: NewsWidgetOptions) { + NewsHomeScreenWidgetOptionsStore.save(options) + NewsHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() + } } diff --git a/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift b/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift new file mode 100644 index 00000000..838c386b --- /dev/null +++ b/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift @@ -0,0 +1,246 @@ +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) + + 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) { + carousel + + sizeLabel + + pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + buttonsRow + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .task { + viewModel.startUpdates() + } + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteAlert, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } + Button(t("common__delete_yes"), role: .destructive) { onDelete() } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": widgetName])) + } + ) + } + + // MARK: - Widget Settings cell + + private var widgetSettingsRow: some View { + Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { + HStack(alignment: .center, spacing: 0) { + BodyMText(t("widgets__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(maxHeight: .infinity) + } + + 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__widget__size_small") + : t("widgets__widget__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/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index e6b80668..2b603f76 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 76ff00a6..114bbdf9 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -307,65 +307,67 @@ enum WidgetEditItemFactory { ) -> [WidgetEditItem] { var items: [WidgetEditItem] = [] + items.append(sectionHeaderItem(key: "news_content_header", title: t("widgets__news__content_header"))) + if let data = newsViewModel.widgetData { items.append( WidgetEditItem( - key: "showDate", + key: "showTitle", type: .toggleItem, - titleView: AnyView(BodyMText(data.timeAgo, textColor: .textPrimary)), + titleView: AnyView(TitleText(data.title)), valueView: nil, - isChecked: newsOptions.showDate + isChecked: newsOptions.showTitle ) ) items.append( WidgetEditItem( - key: "showTitle", - type: .staticItem, - titleView: AnyView(TitleText(data.title)), + key: "showSource", + type: .toggleItem, + titleView: AnyView(BodySSBText(data.publisher, textColor: .brandAccent)), valueView: nil, - isChecked: true // Static items are always shown + isChecked: newsOptions.showSource ) ) items.append( WidgetEditItem( - key: "showSource", + key: "showDate", type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText(data.publisher, textColor: .textSecondary)), - isChecked: newsOptions.showSource + titleView: AnyView(BodySSBText(data.timeAgo, textColor: .textSecondary)), + valueView: nil, + isChecked: newsOptions.showDate ) ) } else { // Fallback when no data is available items.append( WidgetEditItem( - key: "showDate", + key: "showTitle", type: .toggleItem, - titleView: AnyView(BodyMText("13 hours ago", textColor: .textPrimary)), + titleView: AnyView(TitleText("How Bitcoin changed El Salvador in more ways...")), valueView: nil, - isChecked: newsOptions.showDate + isChecked: newsOptions.showTitle ) ) items.append( WidgetEditItem( - key: "showTitle", - type: .staticItem, - titleView: AnyView(TitleText("Exodus Launches XO Pay, An In-App Bitcoin And Crypto Purchase Solution")), + key: "showSource", + type: .toggleItem, + titleView: AnyView(BodySSBText("bitcoinmagazine.com", textColor: .brandAccent)), valueView: nil, - isChecked: true // Static items are always shown + isChecked: newsOptions.showSource ) ) items.append( WidgetEditItem( - key: "showSource", + key: "showDate", type: .toggleItem, - title: t("widgets__widget__source"), - valueView: AnyView(BodySSBText("Bitcoin Magazine", textColor: .textSecondary)), - isChecked: newsOptions.showSource + titleView: AnyView(BodySSBText("1 min ago", textColor: .textSecondary)), + valueView: nil, + isChecked: newsOptions.showDate ) ) } diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift index 737864ec..ba9bbdd0 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 00000000..4f8887ad --- /dev/null +++ b/BitkitWidget/NewsHomeScreenWidget.swift @@ -0,0 +1,277 @@ +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 let refreshInterval: TimeInterval = 15 * 60 + + static func relativeTime(from dateString: String) -> String { + let formatter = DateFormatter() + 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 "" } + + let relative = RelativeDateTimeFormatter() + relative.locale = Locale.current + 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 + +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 = NewsWidgetEntryBuilder.currentArticle(from: cached) + 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 = NewsWidgetEntryBuilder.currentArticle(from: fresh) { + 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 = NewsWidgetEntryBuilder.currentArticle(from: cached) { + 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 = Date().addingTimeInterval(NewsWidgetEntryBuilder.refreshInterval) + 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 + } + } + .widgetURL(articleURL) + .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: 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 { + 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 00000000..c5246b05 --- /dev/null +++ b/BitkitWidget/NewsWidgetService.swift @@ -0,0 +1,58 @@ +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 + } + + static func cachedTopArticles() -> [CachedNewsArticle] { + NewsWidgetCache.loadTop() + } + + static func fetchFreshTopArticles() async throws -> [CachedNewsArticle] { + 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) + + 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? +} diff --git a/changelog.d/next/546.added.md b/changelog.d/next/546.added.md new file mode 100644 index 00000000..7d9db63b --- /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.