Skip to content

Commit 42bc665

Browse files
jvsena42piotr-iohkpwltr
authored
feat: redesign headlines widget v61 + OS widget (#546)
* feat: port price widgets related screens to figma V61 * fix: spacing and alignment * feat: hide menu button from nabigation bar * fix: padding * fix: remove systemLarge widget option * fix: collect results in input order instead of completion order * fix: pr comments * fix: pr comments * refactor: simplify doc * refactor: replace onApper with task * refactor: replace onChange with task id * refactor: simplify comments * refactor: simplyfy comments * refactor: simplify comments * refactor: simplify comments * refactor: simplify comments * refactor: remove multi-pair legacy code * fix: fallback to os widget options after remove in-app * fix: make chart height adaptable * feat: set backgroud color Gray7 * feat: migrate news widget to design v61 and port OS widget * fix: push source text to bottom * refactor: extract articles url to a shared files * feat: open browser on widget click * doc: changelog entry * fix: small and medium sizes displaying different random url * chore: remove schedule file * fix: replace onAppear with task * fix: use stable dafe format identifier * fix: reuse existing text component and remove scale factor * fix: display white32 checkmark for unselected item * fix: vertical padding anchored to checkbox image * fix: remove the gray bg and custom bg from Navigation bar * fix: try to fetch real data for preview * refactor: make string keys generic to be reused in the furue implementtions * fix: make prevew frame height adaptable * fix: display checkmark for title * fix: remove app group fallback * test: widget test ids adjustment * test: widget test ids adjustment * fixes * fixes * fixes --------- Co-authored-by: Piotr Stachyra <piotr.stachyra@gmail.com> Co-authored-by: Philipp Walter <philippwalter@proton.me>
1 parent ab3fde3 commit 42bc665

20 files changed

Lines changed: 853 additions & 149 deletions

Bitkit.xcodeproj/project.pbxproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,11 @@
173173
Fonts/InterTight-Regular.ttf,
174174
Constants/WidgetEnv.swift,
175175
Fonts/InterTight-SemiBold.ttf,
176+
Models/NewsWidgetData.swift,
177+
Models/NewsWidgetOptions.swift,
176178
Models/PriceWidgetData.swift,
177179
Models/PriceWidgetOptions.swift,
180+
Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift,
178181
Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift,
179182
Styles/Colors.swift,
180183
Styles/Fonts.swift,

Bitkit/Components/Widgets/NewsWidget.swift

Lines changed: 77 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,13 @@
11
import SwiftUI
22

3-
/// Options for configuring the NewsWidget
4-
struct NewsWidgetOptions: Codable, Equatable {
5-
var showDate: Bool = true
6-
var showTitle: Bool = true
7-
var showSource: Bool = true
8-
}
9-
10-
/// A widget that displays a news article
3+
/// A widget that displays a news article.
114
struct NewsWidget: View {
12-
/// Configuration options for the widget
135
var options: NewsWidgetOptions = .init()
14-
15-
/// Flag indicating if the widget is in editing mode
166
var isEditing: Bool = false
17-
18-
/// Callback to signal when editing should end
197
var onEditingEnd: (() -> Void)?
208

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

24-
/// Initialize the widget
2511
init(
2612
options: NewsWidgetOptions = NewsWidgetOptions(),
2713
isEditing: Bool = false,
@@ -38,40 +24,92 @@ struct NewsWidget: View {
3824
isEditing: isEditing,
3925
onEditingEnd: onEditingEnd
4026
) {
41-
VStack(spacing: 0) {
42-
if viewModel.isLoading {
43-
WidgetContentBuilder.loadingView()
44-
} else if viewModel.error != nil {
45-
WidgetContentBuilder.errorView(t("widgets__news__error"))
46-
} else if let data = viewModel.widgetData {
47-
if options.showDate {
48-
BodyMText(data.timeAgo, textColor: .textPrimary)
49-
.lineLimit(1)
50-
.frame(maxWidth: .infinity, alignment: .leading)
51-
.padding(.bottom, 16)
27+
content
28+
.contentShape(Rectangle())
29+
.onTapGesture {
30+
if !isEditing, let data = viewModel.widgetData, let url = URL(string: data.link) {
31+
UIApplication.shared.open(url)
5232
}
33+
}
34+
}
35+
.task {
36+
viewModel.startUpdates()
37+
}
38+
}
5339

54-
if options.showTitle {
55-
TitleText(data.title)
56-
.lineLimit(2)
57-
.frame(maxWidth: .infinity, alignment: .leading)
58-
}
40+
@ViewBuilder
41+
private var content: some View {
42+
if viewModel.isLoading && viewModel.widgetData == nil {
43+
WidgetContentBuilder.loadingView()
44+
} else if viewModel.error != nil {
45+
WidgetContentBuilder.errorView(t("widgets__news__error"))
46+
} else if let data = viewModel.widgetData {
47+
NewsWidgetWideContent(data: data, options: options)
48+
}
49+
}
50+
}
51+
52+
// MARK: - Wide layout (in-app + 343-wide carousel page)
53+
54+
struct NewsWidgetWideContent: View {
55+
let data: WidgetData
56+
let options: NewsWidgetOptions
57+
58+
var body: some View {
59+
VStack(alignment: .leading, spacing: 16) {
60+
if options.showTitle {
61+
TitleText(data.title)
62+
.lineLimit(4)
63+
.frame(maxWidth: .infinity, alignment: .leading)
64+
}
5965

66+
if options.showSource || options.showDate {
67+
HStack(alignment: .center, spacing: 8) {
6068
if options.showSource {
61-
WidgetContentBuilder.sourceRow(source: data.publisher)
69+
BodySSBText(data.publisher, textColor: .brandAccent)
70+
.lineLimit(1)
71+
}
72+
Spacer(minLength: 0)
73+
if options.showDate {
74+
BodySSBText(data.timeAgo, textColor: .textSecondary)
75+
.lineLimit(1)
6276
}
6377
}
78+
.frame(maxWidth: .infinity)
79+
}
80+
}
81+
.frame(maxWidth: .infinity, alignment: .leading)
82+
}
83+
}
84+
85+
// MARK: - Compact layout (small carousel preview + 163×192 OS widget)
86+
87+
struct NewsWidgetCompactContent: View {
88+
let data: WidgetData
89+
let options: NewsWidgetOptions
90+
91+
var body: some View {
92+
VStack(alignment: .leading, spacing: 0) {
93+
if options.showTitle {
94+
TitleText(data.title)
95+
.lineLimit(4)
96+
.frame(maxWidth: .infinity, alignment: .leading)
6497
}
65-
.contentShape(Rectangle())
66-
.onTapGesture {
67-
if !isEditing, let data = viewModel.widgetData, let url = URL(string: data.link) {
68-
UIApplication.shared.open(url)
98+
99+
Spacer(minLength: 8)
100+
101+
if options.showDate {
102+
HStack {
103+
Spacer(minLength: 0)
104+
BodySSBText(data.timeAgo, textColor: .textSecondary)
105+
.lineLimit(1)
69106
}
70107
}
71108
}
72-
.onAppear {
73-
viewModel.startUpdates()
74-
}
109+
.padding(16)
110+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
111+
.background(Color.gray6)
112+
.cornerRadius(16)
75113
}
76114
}
77115

Bitkit/Constants/WidgetEnv.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@ import Foundation
77
/// because it depends on framework types that aren't linked into the widget extension.
88
enum WidgetEnv {
99
static let priceFeedBaseUrl = "https://feeds.synonym.to/price-feed/api"
10+
static let newsFeedBaseUrl = "https://feeds.synonym.to/news-feed/api"
11+
static let newsFeedArticlesUrl = "\(newsFeedBaseUrl)/articles"
1012
}

Bitkit/MainNavView.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,12 @@ struct MainNavView: View {
246246
Task {
247247
Logger.info("Received deeplink: \(url.absoluteString)")
248248

249+
// Web URLs from widgets (e.g. news article tap) bypass payment handling
250+
if let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" {
251+
await UIApplication.shared.open(url)
252+
return
253+
}
254+
249255
if let callback = PubkyRingAuthCallback.parse(url: url) {
250256
let handlingResult = await pubkyProfile.handleAuthCallback(callback)
251257

@@ -432,9 +438,12 @@ struct MainNavView: View {
432438
case .widgetsIntro: WidgetsIntroView()
433439
case .widgetsList: WidgetsListView()
434440
case let .widgetDetail(widgetType):
435-
if widgetType == .price {
441+
switch widgetType {
442+
case .price:
436443
PriceWidgetPreviewView()
437-
} else {
444+
case .news:
445+
NewsWidgetPreviewView()
446+
default:
438447
WidgetDetailView(id: widgetType)
439448
}
440449
case let .widgetEdit(widgetType): WidgetEditView(id: widgetType)

Bitkit/Models/NewsWidgetData.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import Foundation
2+
3+
/// Persistable representation of a news article shared between the main app and the widget extension via App Group.
4+
struct CachedNewsArticle: Codable, Equatable {
5+
let title: String
6+
let publisher: String
7+
let link: String
8+
let publishedDate: String
9+
let publishedEpoch: Int
10+
}
11+
12+
/// Cache reader/writer used by both the main app and the widget extension.
13+
enum NewsWidgetCache {
14+
static let appGroupSuiteName = "group.bitkit"
15+
private static let topArticlesKey = "news_widget_top_articles_v1"
16+
private static let legacyStandardKey = "news_widget_cache"
17+
18+
private static func defaults() -> UserDefaults {
19+
UserDefaults(suiteName: appGroupSuiteName) ?? .standard
20+
}
21+
22+
static func saveTop(_ articles: [CachedNewsArticle]) {
23+
guard let encoded = try? JSONEncoder().encode(articles) else { return }
24+
defaults().set(encoded, forKey: topArticlesKey)
25+
}
26+
27+
static func loadTop() -> [CachedNewsArticle] {
28+
guard let data = defaults().data(forKey: topArticlesKey),
29+
let decoded = try? JSONDecoder().decode([CachedNewsArticle].self, from: data)
30+
else {
31+
return []
32+
}
33+
return decoded
34+
}
35+
36+
/// One-time cleanup of the pre-App-Group single-`WidgetData` cache.
37+
static func legacyDropStandardSuiteCache() {
38+
UserDefaults.standard.removeObject(forKey: legacyStandardKey)
39+
}
40+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Foundation
2+
3+
/// Options for configuring the in-app and home-screen news widgets (shared via App Group).
4+
struct NewsWidgetOptions: Codable, Equatable {
5+
var showDate: Bool = true
6+
var showTitle: Bool = true
7+
var showSource: Bool = true
8+
9+
init(showDate: Bool = true, showTitle: Bool = true, showSource: Bool = true) {
10+
self.showDate = showDate
11+
self.showTitle = showTitle
12+
self.showSource = showSource
13+
}
14+
}

Bitkit/Resources/Localization/en.lproj/Localizable.strings

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1382,7 +1382,6 @@
13821382
"widgets__widget__edit" = "Widget Feed";
13831383
"widgets__widget__edit_default" = "Default";
13841384
"widgets__widget__edit_custom" = "Custom";
1385-
"widgets__widget__edit_description" = "Please select which fields you want to display in the {name} widget.";
13861385
"widgets__widget__source" = "Source";
13871386
"widgets__add" = "Add Widget";
13881387
"widgets__list__button" = "Enable In Settings";
@@ -1404,6 +1403,7 @@
14041403
"widgets__news__name" = "Bitcoin Headlines";
14051404
"widgets__news__description" = "Read the latest & greatest Bitcoin headlines from various news sites.";
14061405
"widgets__news__error" = "Couldn\'t get the latest news";
1406+
"widgets__news__content_header" = "Content";
14071407
"widgets__blocks__name" = "Bitcoin Blocks";
14081408
"widgets__blocks__description" = "Examine various statistics on newly mined Bitcoin Blocks.";
14091409
"widgets__blocks__error" = "Couldn\'t get blocks data";
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import Foundation
2+
import WidgetKit
3+
4+
/// Mirrors in-app news widget options into the App Group so the WidgetKit extension can read them,
5+
/// and centralizes the WidgetKit reload trigger for the news home-screen widget.
6+
enum NewsHomeScreenWidgetOptionsStore {
7+
/// WidgetKit `kind` for the home-screen news widget (must match `BitkitNewsWidget`).
8+
static let newsHomeScreenWidgetKind = "BitkitNewsWidget"
9+
10+
private static let suiteName = "group.bitkit"
11+
private static let key = "home_screen_news_widget_options_v1"
12+
13+
static func save(_ options: NewsWidgetOptions) {
14+
guard let defaults = UserDefaults(suiteName: suiteName),
15+
let data = try? JSONEncoder().encode(options)
16+
else { return }
17+
defaults.set(data, forKey: key)
18+
}
19+
20+
static func load() -> NewsWidgetOptions {
21+
guard let defaults = UserDefaults(suiteName: suiteName),
22+
let data = defaults.data(forKey: key),
23+
let options = try? JSONDecoder().decode(NewsWidgetOptions.self, from: data)
24+
else {
25+
return NewsWidgetOptions()
26+
}
27+
return options
28+
}
29+
30+
/// Call after updating options or cache so the home-screen widget timeline refreshes.
31+
/// No-op when running inside the widget extension itself (`appex`).
32+
static func reloadHomeScreenWidgetIfNeeded() {
33+
guard Bundle.main.bundleURL.pathExtension != "appex" else { return }
34+
WidgetCenter.shared.reloadTimelines(ofKind: newsHomeScreenWidgetKind)
35+
}
36+
}

0 commit comments

Comments
 (0)