Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
}
}
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
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