11import Charts
22import 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.
55struct 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
109144struct 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
0 commit comments