Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
116 changes: 77 additions & 39 deletions Bitkit/Components/Widgets/NewsWidget.swift
Original file line number Diff line number Diff line change
@@ -1,27 +1,13 @@
import SwiftUI

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Spacer(minLength: 8)

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

Expand Down
2 changes: 2 additions & 0 deletions Bitkit/Constants/WidgetEnv.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
13 changes: 11 additions & 2 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions Bitkit/Models/NewsWidgetData.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
14 changes: 14 additions & 0 deletions Bitkit/Models/NewsWidgetOptions.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 4 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
"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";
Expand Down
36 changes: 36 additions & 0 deletions Bitkit/Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading