Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
61db06c
feat: port price widgets related screens to figma V61
jvsena42 May 6, 2026
28a550f
fix: spacing and alignment
jvsena42 May 6, 2026
10be29e
feat: hide menu button from nabigation bar
jvsena42 May 6, 2026
90680fe
fix: padding
jvsena42 May 6, 2026
4dfd9a8
fix: remove systemLarge widget option
jvsena42 May 6, 2026
15dc374
fix: collect results in input order instead of completion order
jvsena42 May 6, 2026
fc2aaa6
Merge remote-tracking branch 'origin/feat/os-widgets' into feat/price…
jvsena42 May 6, 2026
600b422
fix: pr comments
jvsena42 May 6, 2026
027da41
fix: pr comments
jvsena42 May 6, 2026
a172129
refactor: simplify doc
jvsena42 May 6, 2026
73eba60
refactor: replace onApper with task
jvsena42 May 6, 2026
d07317f
refactor: replace onChange with task id
jvsena42 May 6, 2026
8b2ec97
refactor: simplify comments
jvsena42 May 6, 2026
6586143
refactor: simplyfy comments
jvsena42 May 6, 2026
fc0dc8e
refactor: simplify comments
jvsena42 May 6, 2026
d01c196
refactor: simplify comments
jvsena42 May 6, 2026
770511e
refactor: simplify comments
jvsena42 May 6, 2026
5f0841f
refactor: remove multi-pair legacy code
jvsena42 May 6, 2026
51c2f41
fix: fallback to os widget options after remove in-app
jvsena42 May 6, 2026
afb421a
fix: make chart height adaptable
jvsena42 May 6, 2026
fdafc5b
Merge branch 'feat/os-widgets' into feat/price-widget-v61
jvsena42 May 6, 2026
5be6014
feat: set backgroud color Gray7
jvsena42 May 6, 2026
b0d05be
Merge branch 'feat/price-widget-v61' of github.com:synonymdev/bitkit-…
jvsena42 May 6, 2026
22dc3e1
Merge branch 'feat/os-widgets' into feat/price-widget-v61
jvsena42 May 7, 2026
6e3ab90
fix: reuse existing text component and remove scale factor
jvsena42 May 7, 2026
b2df71f
fix: display white32 checkmark for unselected item
jvsena42 May 7, 2026
7994460
fix: vertical padding anchored to checkbox image
jvsena42 May 7, 2026
200d2f8
fix: remove the gray bg and custom bg from Navigation bar
jvsena42 May 7, 2026
870d5e8
fix: try to fetch real data for preview
jvsena42 May 7, 2026
d2f4120
refactor: make string keys generic to be reused in the furue implemen…
jvsena42 May 7, 2026
1c8350a
fix: make prevew frame height adaptable
jvsena42 May 7, 2026
88843b6
fix: remove app group fallback
jvsena42 May 7, 2026
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
226 changes: 102 additions & 124 deletions Bitkit/Components/Widgets/PriceWidget.swift
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
import Charts
import SwiftUI

/// A widget that displays cryptocurrency price information with chart
/// Displays Bitcoin price for the user's selected trading pair and timeframe (Figma v61).
struct PriceWidget: View {
/// Configuration options for the widget
var options: PriceWidgetOptions = .init()

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

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

/// Price view model singleton
@StateObject private var viewModel = PriceViewModel.shared

/// Initialize the widget
init(
options: PriceWidgetOptions = PriceWidgetOptions(),
isEditing: Bool = false,
Expand All @@ -32,91 +25,121 @@ struct PriceWidget: View {
isEditing: isEditing,
onEditingEnd: onEditingEnd
) {
VStack(spacing: 0) {
if viewModel.isLoading && filteredPriceData.isEmpty {
WidgetContentBuilder.loadingView()
} else if viewModel.error != nil {
WidgetContentBuilder.errorView(t("widgets__price__error"))
} else {
ForEach(filteredPriceData, id: \.name) { priceData in
PriceRow(data: priceData)
.accessibilityIdentifier("PriceWidgetRow-\(priceData.name)")
}
}

if let firstPair = filteredPriceData.first {
PriceChart(
values: firstPair.pastValues,
isPositive: firstPair.change.isPositive,
period: options.selectedPeriod.rawValue
)
.frame(height: 96)
.padding(.top, 8)
}

if options.showSource {
WidgetContentBuilder.sourceRow(source: "Bitfinex.com")
.accessibilityIdentifier("PriceWidgetSource")
}
}
content
}
.onAppear {
fetchPriceData()
}
.onChange(of: options.selectedPairs) {
fetchPriceData()
}
.onChange(of: options.selectedPeriod) {
fetchPriceData()
.onAppear { fetchPriceData() }
.onChange(of: options.selectedPairs) { fetchPriceData() }
.onChange(of: options.selectedPeriod) { fetchPriceData() }
}

@ViewBuilder
private var content: some View {
if viewModel.isLoading && primaryPrice == nil {
WidgetContentBuilder.loadingView()
} else if viewModel.error != nil {
WidgetContentBuilder.errorView(t("widgets__price__error"))
} else if let primary = primaryPrice {
PriceWidgetWideContent(data: primary, period: options.selectedPeriod)
}
}

private var filteredPriceData: [PriceData] {
/// Single pair (v61). Falls back to first available data if the selection isn't loaded yet.
private var primaryPrice: PriceData? {
let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod)
let dataByPair = Dictionary(uniqueKeysWithValues: currentPeriodData.map { ($0.name, $0) })
return options.selectedPairs.compactMap { pair in
dataByPair[pair]
if let preferred = options.selectedPairs.first,
let match = currentPeriodData.first(where: { $0.name == preferred })
{
return match
}
return currentPeriodData.first
}

/// Fetch price data from view model
private func fetchPriceData() {
viewModel.fetchPriceData(pairs: options.selectedPairs, period: options.selectedPeriod)
}
}

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

struct PriceWidgetWideContent: View {
let data: PriceData
let period: GraphPeriod

var body: some View {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .center, spacing: 16) {
CaptionMText("\(data.name) \(period.rawValue)", textColor: .textSecondary)
.textCase(.uppercase)
.frame(maxWidth: .infinity, alignment: .leading)

Text(data.change.formatted)
.font(Fonts.bold(size: 22))
.foregroundColor(data.change.isPositive ? .greenAccent : .redAccent)
.lineLimit(1)
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
}

Text(data.price)
.font(Fonts.bold(size: 34))
.foregroundColor(.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)
.frame(maxWidth: .infinity, alignment: .leading)
}

PriceChart(values: data.pastValues, isPositive: data.change.isPositive)
.frame(height: 48)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}

// MARK: - Compact layout (small carousel preview only)

struct PriceRow: View {
struct PriceWidgetCompactContent: View {
let data: PriceData
let period: GraphPeriod

var body: some View {
HStack {
BodySSBText(data.name, textColor: .textSecondary)
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 0) {
CaptionMText(data.name, textColor: .textSecondary)
.textCase(.uppercase)
Spacer(minLength: 0)
CaptionMText(period.rawValue, textColor: .textSecondary)
.textCase(.uppercase)
}

Text(data.price)
.font(Fonts.bold(size: 22))
.foregroundColor(.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)

Spacer()
Text(data.change.formatted)
.font(Fonts.semiBold(size: 15))
.foregroundColor(data.change.isPositive ? .greenAccent : .redAccent)
.lineLimit(1)
}

BodySSBText(data.change.formatted, textColor: data.change.isPositive ? .greenAccent : .redAccent)
.padding(.trailing, 8)
BodySSBText(data.price, textColor: .textPrimary)
PriceChart(values: data.pastValues, isPositive: data.change.isPositive)
.frame(height: 64)
}
.frame(minHeight: 28)
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Color.gray6)
.cornerRadius(16)
}
}

// MARK: - Price Chart Component
// MARK: - Chart (line-only per Figma v61)

struct PriceChart: View {
let values: [Double]
let isPositive: Bool
let period: String

// Chart styling constants
private let lineWidth: CGFloat = 1.3
private let chartPadding: CGFloat = 4
private let cornerRadius: CGFloat = 8
private let gradientOpacityTop: CGFloat = 0.64
private let gradientOpacityBottom: CGFloat = 0.08

private var normalizedValues: [Double] {
guard values.count > 1 else { return values }
Expand All @@ -127,76 +150,31 @@ struct PriceChart: View {

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

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

private var chartColors: (gradient: [Color], line: Color) {
if isPositive {
return (
gradient: [.greenAccent.opacity(gradientOpacityTop), .greenAccent.opacity(gradientOpacityBottom)],
line: .greenAccent
)
} else {
return (
gradient: [.redAccent.opacity(gradientOpacityTop), .redAccent.opacity(gradientOpacityBottom)],
line: .redAccent
)
}
private var lineColor: Color {
isPositive ? .greenAccent : .redAccent
}

var body: some View {
ZStack(alignment: .bottomLeading) {
Chart {
ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in
// Area fill with gradient
AreaMark(
x: .value("Index", index),
y: .value("Price", value)
)
.foregroundStyle(
LinearGradient(
colors: chartColors.gradient,
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.catmullRom)

// Line on top
LineMark(
x: .value("Index", index),
y: .value("Price", value)
)
.foregroundStyle(chartColors.line)
.lineStyle(StrokeStyle(lineWidth: lineWidth))
.interpolationMethod(.catmullRom)
}
}
.chartXAxis(.hidden)
.chartYAxis(.hidden)
// Y scale domain provides buffer zone beyond data range (0.15...0.85)
// This ensures chart elements (lines, curves) don't get clipped at edges
.chartYScale(domain: 0.1 ... 0.9) // Domain slightly larger than data range for extra buffer
// Apply rounded corners only to bottom - chart content extends to edges for visible clipping
// The internal margins above prevent any actual data from being cut off
.clipShape(
.rect(
topLeadingRadius: 0,
bottomLeadingRadius: cornerRadius,
bottomTrailingRadius: cornerRadius,
topTrailingRadius: 0
Chart {
ForEach(Array(normalizedValues.enumerated()), id: \.offset) { index, value in
LineMark(
x: .value("Index", index),
y: .value("Price", value)
)
)

// Period label
CaptionBText(period, textColor: isPositive ? .green50 : .red50)
.padding(7)
.foregroundStyle(lineColor)
.lineStyle(StrokeStyle(lineWidth: lineWidth))
.interpolationMethod(.catmullRom)
}
}
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.chartYScale(domain: 0.1 ... 0.9)
}
}

Expand Down
7 changes: 6 additions & 1 deletion Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,12 @@ struct MainNavView: View {
// Widgets
case .widgetsIntro: WidgetsIntroView()
case .widgetsList: WidgetsListView()
case let .widgetDetail(widgetType): WidgetDetailView(id: widgetType)
case let .widgetDetail(widgetType):
if widgetType == .price {
PriceWidgetPreviewView()
} else {
WidgetDetailView(id: widgetType)
}
case let .widgetEdit(widgetType): WidgetEditView(id: widgetType)

// Settings
Expand Down
4 changes: 3 additions & 1 deletion Bitkit/Models/PriceWidgetOptions.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import Foundation

/// Options for configuring the in-app and home-screen price widgets (shared via App Group for the extension).
///
/// `selectedPairs` is kept as an array for storage backwards-compatibility with v60. The v61 UI is
/// single-select and only ever reads/writes `[firstPair]`.
struct PriceWidgetOptions: Codable, Equatable {
var selectedPairs: [String] = ["BTC/USD"]
var selectedPeriod: GraphPeriod = .oneDay
var showSource: Bool = false
}
10 changes: 10 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1387,6 +1387,16 @@
"widgets__price__name" = "Bitcoin Price";
"widgets__price__description" = "Check the latest Bitcoin exchange rates for a variety of fiat currencies.";
"widgets__price__error" = "Couldn\'t get price data";
"widgets__price__currency" = "Currency";
"widgets__price__timeframe" = "Timeframe";
"widgets__price__period_day" = "Day";
"widgets__price__period_week" = "Week";
"widgets__price__period_month" = "Month";
"widgets__price__period_year" = "Year";
"widgets__price__size_small" = "Small";
"widgets__price__size_wide" = "Wide";
"widgets__price__widget_settings" = "Widget Settings";
"widgets__widget__save_widget" = "Save Widget";
"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";
Expand Down
5 changes: 1 addition & 4 deletions Bitkit/Utilities/WidgetsBackupConverter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ enum WidgetsBackupConverter {
pricePreferences = [
"enabledPairs": androidPairs.isEmpty ? ["BTC_USD"] : androidPairs,
"period": androidPeriod,
"showSource": options.showSource,
]
}
case .calculator, .suggestions:
Expand Down Expand Up @@ -179,8 +178,7 @@ enum WidgetsBackupConverter {
let period = convertAndroidPeriodToIos(prefs["period"] as? String)
let iosOptions = PriceWidgetOptions(
selectedPairs: selectedPairs,
selectedPeriod: period,
showSource: prefs["showSource"] as? Bool ?? false
selectedPeriod: period
)
optionsData = try? JSONEncoder().encode(iosOptions)
}
Expand Down Expand Up @@ -243,7 +241,6 @@ enum WidgetsBackupConverter {
return [
"enabledPairs": androidPairs.isEmpty ? ["BTC_USD"] : androidPairs,
"period": androidPeriod,
"showSource": defaults.showSource,
]
}

Expand Down
Loading
Loading