Skip to content

Commit ab3fde3

Browse files
jvsena42piotr-iohkpwltr
authored
feat: price widget v61 (#542)
* 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 * 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: remove app group fallback * test: widget test ids adjustment * fixes --------- Co-authored-by: Piotr Stachyra <piotr.stachyra@gmail.com> Co-authored-by: Philipp Walter <philippwalter@proton.me>
1 parent bc590d3 commit ab3fde3

15 files changed

Lines changed: 708 additions & 377 deletions

Bitkit/Components/Widgets/PriceWidget.swift

Lines changed: 109 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,14 @@
11
import Charts
22
import SwiftUI
33

4-
/// A widget that displays cryptocurrency price information with chart
4+
/// Displays Bitcoin price for the user's selected trading pair and timeframe.
55
struct PriceWidget: View {
6-
/// Configuration options for the widget
76
var options: PriceWidgetOptions = .init()
8-
9-
/// Flag indicating if the widget is in editing mode
107
var isEditing: Bool = false
11-
12-
/// Callback to signal when editing should end
138
var onEditingEnd: (() -> Void)?
149

15-
/// Price view model singleton
1610
@StateObject private var viewModel = PriceViewModel.shared
1711

18-
/// Initialize the widget
1912
init(
2013
options: PriceWidgetOptions = PriceWidgetOptions(),
2114
isEditing: Bool = false,
@@ -32,91 +25,127 @@ struct PriceWidget: View {
3225
isEditing: isEditing,
3326
onEditingEnd: onEditingEnd
3427
) {
35-
VStack(spacing: 0) {
36-
if viewModel.isLoading && filteredPriceData.isEmpty {
37-
WidgetContentBuilder.loadingView()
38-
} else if viewModel.error != nil {
39-
WidgetContentBuilder.errorView(t("widgets__price__error"))
40-
} else {
41-
ForEach(filteredPriceData, id: \.name) { priceData in
42-
PriceRow(data: priceData)
43-
.accessibilityIdentifier("PriceWidgetRow-\(priceData.name)")
44-
}
45-
}
46-
47-
if let firstPair = filteredPriceData.first {
48-
PriceChart(
49-
values: firstPair.pastValues,
50-
isPositive: firstPair.change.isPositive,
51-
period: options.selectedPeriod.rawValue
52-
)
53-
.frame(height: 96)
54-
.padding(.top, 8)
55-
}
56-
57-
if options.showSource {
58-
WidgetContentBuilder.sourceRow(source: "Bitfinex.com")
59-
.accessibilityIdentifier("PriceWidgetSource")
60-
}
61-
}
28+
content
6229
}
63-
.onAppear {
64-
fetchPriceData()
65-
}
66-
.onChange(of: options.selectedPairs) {
67-
fetchPriceData()
68-
}
69-
.onChange(of: options.selectedPeriod) {
70-
fetchPriceData()
30+
.task(id: options) { fetchPriceData() }
31+
}
32+
33+
@ViewBuilder
34+
private var content: some View {
35+
if viewModel.isLoading && primaryPrice == nil {
36+
WidgetContentBuilder.loadingView()
37+
} else if viewModel.error != nil {
38+
WidgetContentBuilder.errorView(t("widgets__price__error"))
39+
} else if let primary = primaryPrice {
40+
PriceWidgetWideContent(data: primary, period: options.selectedPeriod)
7141
}
7242
}
7343

74-
private var filteredPriceData: [PriceData] {
44+
/// Single pair. Falls back to first available data if the selection isn't loaded yet.
45+
private var primaryPrice: PriceData? {
7546
let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod)
76-
let dataByPair = Dictionary(uniqueKeysWithValues: currentPeriodData.map { ($0.name, $0) })
77-
return options.selectedPairs.compactMap { pair in
78-
dataByPair[pair]
47+
if let match = currentPeriodData.first(where: { $0.name == options.selectedPair }) {
48+
return match
7949
}
50+
return currentPeriodData.first
8051
}
8152

82-
/// Fetch price data from view model
8353
private func fetchPriceData() {
84-
viewModel.fetchPriceData(pairs: options.selectedPairs, period: options.selectedPeriod)
54+
viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod)
8555
}
8656
}
8757

88-
// MARK: - Price Row Component
58+
// MARK: - Wide layout (in-app + carousel page)
8959

90-
struct PriceRow: View {
60+
struct PriceWidgetWideContent: View {
9161
let data: PriceData
62+
let period: GraphPeriod
9263

9364
var body: some View {
94-
HStack {
95-
BodySSBText(data.name, textColor: .textSecondary)
65+
VStack(alignment: .leading, spacing: 8) {
66+
VStack(alignment: .leading, spacing: 4) {
67+
HStack(alignment: .center, spacing: 16) {
68+
CaptionMText("\(data.name) \(period.rawValue)", textColor: .textSecondary)
69+
.textCase(.uppercase)
70+
.frame(maxWidth: .infinity, alignment: .leading)
71+
72+
TitleText(
73+
data.change.formatted,
74+
textColor: data.change.isPositive ? .greenAccent : .redAccent
75+
)
76+
.lineLimit(1)
77+
.accessibilityIdentifier("price_card_pair_change_\(data.name)")
78+
}
79+
.accessibilityIdentifier("PriceWidgetRow-\(data.name)")
80+
81+
Text(data.price)
82+
.font(Fonts.bold(size: 34))
83+
.foregroundColor(.textPrimary)
84+
.lineLimit(1)
85+
.minimumScaleFactor(0.7)
86+
.frame(maxWidth: .infinity, alignment: .leading)
87+
.accessibilityIdentifier("price_card_pair_price_\(data.name)")
88+
}
89+
90+
PriceChart(values: data.pastValues, isPositive: data.change.isPositive)
91+
.frame(height: 48)
92+
.accessibilityIdentifier("price_card_chart")
93+
}
94+
.frame(maxWidth: .infinity, alignment: .leading)
95+
}
96+
}
97+
98+
// MARK: - Compact layout (small carousel preview only)
99+
100+
struct PriceWidgetCompactContent: View {
101+
let data: PriceData
102+
let period: GraphPeriod
96103

97-
Spacer()
104+
var body: some View {
105+
VStack(alignment: .leading, spacing: 16) {
106+
VStack(alignment: .leading, spacing: 8) {
107+
HStack(spacing: 0) {
108+
CaptionMText(data.name, textColor: .textSecondary)
109+
.textCase(.uppercase)
110+
Spacer(minLength: 0)
111+
CaptionMText(period.rawValue, textColor: .textSecondary)
112+
.textCase(.uppercase)
113+
}
114+
.accessibilityIdentifier("price_card_small_pair_row_\(data.name)")
115+
116+
Text(data.price)
117+
.font(Fonts.bold(size: 22))
118+
.foregroundColor(.textPrimary)
119+
.lineLimit(1)
120+
.minimumScaleFactor(0.7)
121+
.accessibilityIdentifier("price_card_small_pair_price_\(data.name)")
122+
123+
BodySSBText(
124+
data.change.formatted,
125+
textColor: data.change.isPositive ? .greenAccent : .redAccent
126+
)
127+
.lineLimit(1)
128+
.accessibilityIdentifier("price_card_small_pair_change_\(data.name)")
129+
}
98130

99-
BodySSBText(data.change.formatted, textColor: data.change.isPositive ? .greenAccent : .redAccent)
100-
.padding(.trailing, 8)
101-
BodySSBText(data.price, textColor: .textPrimary)
131+
PriceChart(values: data.pastValues, isPositive: data.change.isPositive)
132+
.frame(height: 64)
133+
.accessibilityIdentifier("price_card_small_chart")
102134
}
103-
.frame(minHeight: 28)
135+
.padding(16)
136+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
137+
.background(Color.gray6)
138+
.cornerRadius(16)
104139
}
105140
}
106141

107-
// MARK: - Price Chart Component
142+
// MARK: - Chart
108143

109144
struct PriceChart: View {
110145
let values: [Double]
111146
let isPositive: Bool
112-
let period: String
113147

114-
// Chart styling constants
115148
private let lineWidth: CGFloat = 1.3
116-
private let chartPadding: CGFloat = 4
117-
private let cornerRadius: CGFloat = 8
118-
private let gradientOpacityTop: CGFloat = 0.64
119-
private let gradientOpacityBottom: CGFloat = 0.08
120149

121150
private var normalizedValues: [Double] {
122151
guard values.count > 1 else { return values }
@@ -127,76 +156,31 @@ struct PriceChart: View {
127156

128157
guard range > 0 else { return values.map { _ in 0.5 } }
129158

130-
// Map to 0.15...0.85 range for more generous margins
131-
// This prevents chart content from reaching the very edges where clipping occurs
132159
return values.map { value in
133160
let normalized = (value - minValue) / range
134-
return 0.15 + (normalized * 0.7) // Maps 0-1 to 0.15-0.85
161+
return 0.15 + (normalized * 0.7)
135162
}
136163
}
137164

138-
private var chartColors: (gradient: [Color], line: Color) {
139-
if isPositive {
140-
return (
141-
gradient: [.greenAccent.opacity(gradientOpacityTop), .greenAccent.opacity(gradientOpacityBottom)],
142-
line: .greenAccent
143-
)
144-
} else {
145-
return (
146-
gradient: [.redAccent.opacity(gradientOpacityTop), .redAccent.opacity(gradientOpacityBottom)],
147-
line: .redAccent
148-
)
149-
}
165+
private var lineColor: Color {
166+
isPositive ? .greenAccent : .redAccent
150167
}
151168

152169
var body: some View {
153-
ZStack(alignment: .bottomLeading) {
154-
Chart {
155-
ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in
156-
// Area fill with gradient
157-
AreaMark(
158-
x: .value("Index", index),
159-
y: .value("Price", value)
160-
)
161-
.foregroundStyle(
162-
LinearGradient(
163-
colors: chartColors.gradient,
164-
startPoint: .top,
165-
endPoint: .bottom
166-
)
167-
)
168-
.interpolationMethod(.catmullRom)
169-
170-
// Line on top
171-
LineMark(
172-
x: .value("Index", index),
173-
y: .value("Price", value)
174-
)
175-
.foregroundStyle(chartColors.line)
176-
.lineStyle(StrokeStyle(lineWidth: lineWidth))
177-
.interpolationMethod(.catmullRom)
178-
}
179-
}
180-
.chartXAxis(.hidden)
181-
.chartYAxis(.hidden)
182-
// Y scale domain provides buffer zone beyond data range (0.15...0.85)
183-
// This ensures chart elements (lines, curves) don't get clipped at edges
184-
.chartYScale(domain: 0.1 ... 0.9) // Domain slightly larger than data range for extra buffer
185-
// Apply rounded corners only to bottom - chart content extends to edges for visible clipping
186-
// The internal margins above prevent any actual data from being cut off
187-
.clipShape(
188-
.rect(
189-
topLeadingRadius: 0,
190-
bottomLeadingRadius: cornerRadius,
191-
bottomTrailingRadius: cornerRadius,
192-
topTrailingRadius: 0
170+
Chart {
171+
ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in
172+
LineMark(
173+
x: .value("Index", index),
174+
y: .value("Price", value)
193175
)
194-
)
195-
196-
// Period label
197-
CaptionBText(period, textColor: isPositive ? .green50 : .red50)
198-
.padding(7)
176+
.foregroundStyle(lineColor)
177+
.lineStyle(StrokeStyle(lineWidth: lineWidth))
178+
.interpolationMethod(.catmullRom)
179+
}
199180
}
181+
.chartXAxis(.hidden)
182+
.chartYAxis(.hidden)
183+
.chartYScale(domain: 0.1 ... 0.9)
200184
}
201185
}
202186

Bitkit/MainNavView.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,12 @@ struct MainNavView: View {
431431
// Widgets
432432
case .widgetsIntro: WidgetsIntroView()
433433
case .widgetsList: WidgetsListView()
434-
case let .widgetDetail(widgetType): WidgetDetailView(id: widgetType)
434+
case let .widgetDetail(widgetType):
435+
if widgetType == .price {
436+
PriceWidgetPreviewView()
437+
} else {
438+
WidgetDetailView(id: widgetType)
439+
}
435440
case let .widgetEdit(widgetType): WidgetEditView(id: widgetType)
436441

437442
// Settings
Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,41 @@
11
import Foundation
22

3-
/// Options for configuring the in-app and home-screen price widgets (shared via App Group for the extension).
3+
/// Options for configuring the in-app and home-screen price widgets (shared via App Group).
4+
///
45
struct PriceWidgetOptions: Codable, Equatable {
5-
var selectedPairs: [String] = ["BTC/USD"]
6+
var selectedPair: String = "BTC/USD"
67
var selectedPeriod: GraphPeriod = .oneDay
7-
var showSource: Bool = false
8+
9+
init(selectedPair: String = "BTC/USD", selectedPeriod: GraphPeriod = .oneDay) {
10+
self.selectedPair = selectedPair
11+
self.selectedPeriod = selectedPeriod
12+
}
13+
14+
private enum CodingKeys: String, CodingKey {
15+
case selectedPair
16+
case selectedPairs // legacy v60 key
17+
case selectedPeriod
18+
}
19+
20+
init(from decoder: Decoder) throws {
21+
let container = try decoder.container(keyedBy: CodingKeys.self)
22+
23+
if let pair = try container.decodeIfPresent(String.self, forKey: .selectedPair) {
24+
selectedPair = pair
25+
} else if let legacyPairs = try container.decodeIfPresent([String].self, forKey: .selectedPairs),
26+
let first = legacyPairs.first
27+
{
28+
selectedPair = first
29+
} else {
30+
selectedPair = "BTC/USD"
31+
}
32+
33+
selectedPeriod = try container.decodeIfPresent(GraphPeriod.self, forKey: .selectedPeriod) ?? .oneDay
34+
}
35+
36+
func encode(to encoder: Encoder) throws {
37+
var container = encoder.container(keyedBy: CodingKeys.self)
38+
try container.encode(selectedPair, forKey: .selectedPair)
39+
try container.encode(selectedPeriod, forKey: .selectedPeriod)
40+
}
841
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,6 +1391,16 @@
13911391
"widgets__price__name" = "Bitcoin Price";
13921392
"widgets__price__description" = "Check the latest Bitcoin exchange rates for a variety of fiat currencies.";
13931393
"widgets__price__error" = "Couldn\'t get price data";
1394+
"widgets__price__currency" = "Currency";
1395+
"widgets__price__timeframe" = "Timeframe";
1396+
"widgets__price__period_day" = "Day";
1397+
"widgets__price__period_week" = "Week";
1398+
"widgets__price__period_month" = "Month";
1399+
"widgets__price__period_year" = "Year";
1400+
"widgets__widget__size_small" = "Small";
1401+
"widgets__widget__size_wide" = "Wide";
1402+
"widgets__widget__settings" = "Widget Settings";
1403+
"widgets__widget__save_widget" = "Save Widget";
13941404
"widgets__news__name" = "Bitcoin Headlines";
13951405
"widgets__news__description" = "Read the latest & greatest Bitcoin headlines from various news sites.";
13961406
"widgets__news__error" = "Couldn\'t get the latest news";

0 commit comments

Comments
 (0)