Skip to content

Commit 407dc4f

Browse files
committed
fix: implement a currency formmating single source of true and apply it to weather widget. also implemented unit tests for preventing regressions
1 parent 2ffedd8 commit 407dc4f

7 files changed

Lines changed: 158 additions & 32 deletions

File tree

Bitkit.xcodeproj/project.pbxproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@
129129
Models/BlocksWidgetData.swift,
130130
Models/BlocksWidgetFields.swift,
131131
Models/BlocksWidgetOptions.swift,
132+
Models/Currency.swift,
132133
Models/NewsWidgetData.swift,
133134
Models/NewsWidgetOptions.swift,
134135
Models/PriceWidgetData.swift,

Bitkit/Models/Currency.swift

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,7 @@ struct ConvertedAmount {
5555
}
5656

5757
func formattedWithSymbol(withSpace: Bool = false) -> String {
58-
let separator = withSpace ? " " : ""
59-
return isSymbolSuffix ? "\(formatted)\(separator)\(symbol)" : "\(symbol)\(separator)\(formatted)"
58+
formatFiatWithSymbol(formatted: formatted, symbol: symbol, currencyCode: currency, withSpace: withSpace)
6059
}
6160

6261
struct BitcoinDisplayComponents {
@@ -89,6 +88,30 @@ func isSuffixSymbolCurrency(_ currencyCode: String) -> Bool {
8988
suffixSymbolCurrencies.contains(currencyCode)
9089
}
9190

91+
/// Formats a fiat amount the way the in-app currency display does: 2 fraction digits,
92+
/// "." decimal / "," grouping. Single source of truth shared by the app and the widget
93+
/// extension so both produce identical strings.
94+
func formatFiatAmount(_ value: Decimal) -> String {
95+
let formatter = NumberFormatter()
96+
formatter.numberStyle = .decimal
97+
formatter.minimumFractionDigits = 2
98+
formatter.maximumFractionDigits = 2
99+
formatter.locale = Locale.current
100+
formatter.decimalSeparator = "."
101+
formatter.groupingSeparator = ","
102+
return formatter.string(from: value as NSDecimalNumber) ?? "0.00"
103+
}
104+
105+
/// Composes a formatted number with its currency symbol, honoring suffix-symbol currencies.
106+
/// Using the backend-provided symbol here (rather than a locale-derived one) keeps USD as
107+
/// "$" instead of the disambiguated "US$".
108+
func formatFiatWithSymbol(formatted: String, symbol: String, currencyCode: String, withSpace: Bool = false) -> String {
109+
let separator = withSpace ? " " : ""
110+
return isSuffixSymbolCurrency(currencyCode)
111+
? "\(formatted)\(separator)\(symbol)"
112+
: "\(symbol)\(separator)\(formatted)"
113+
}
114+
92115
private let suffixSymbolCurrencies: Set<String> = [
93116
"BGN", // Bulgarian Lev (10,00 лв)
94117
"CHF", // Swiss Franc (10.00 CHF)

Bitkit/Services/CurrencyService.swift

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -76,21 +76,9 @@ extension CurrencyService {
7676
let btcAmount = Decimal(sats) / 100_000_000
7777
let value = btcAmount * rate.rate
7878

79-
let formatter = NumberFormatter()
80-
formatter.numberStyle = .decimal
81-
formatter.minimumFractionDigits = 2
82-
formatter.maximumFractionDigits = 2
83-
formatter.locale = Locale.current
84-
formatter.decimalSeparator = "."
85-
formatter.groupingSeparator = ","
86-
87-
guard let formatted = formatter.string(from: value as NSDecimalNumber) else {
88-
return nil
89-
}
90-
9179
return ConvertedAmount(
9280
value: value,
93-
formatted: formatted,
81+
formatted: formatFiatAmount(value),
9482
symbol: rate.currencySymbol,
9583
currency: rate.quote,
9684
flag: rate.currencyFlag,

Bitkit/Services/Widgets/WeatherHomeScreenWidgetOptionsStore.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,14 @@ enum WeatherHomeScreenWidgetOptionsStore {
3737
enum WeatherCurrencyAppGroupStore {
3838
private static let suiteName = "group.bitkit"
3939
private static let key = "home_screen_display_currency_code_v1"
40+
private static let symbolKey = "home_screen_display_currency_symbol_v1"
4041
static let fallbackCode = "USD"
42+
static let fallbackSymbol = "$"
4143

42-
static func save(code: String) {
44+
static func save(code: String, symbol: String) {
4345
guard let defaults = UserDefaults(suiteName: suiteName) else { return }
4446
defaults.set(code, forKey: key)
47+
defaults.set(symbol, forKey: symbolKey)
4548
}
4649

4750
static func load() -> String {
@@ -53,4 +56,14 @@ enum WeatherCurrencyAppGroupStore {
5356
}
5457
return code
5558
}
59+
60+
static func loadSymbol() -> String {
61+
guard let defaults = UserDefaults(suiteName: suiteName),
62+
let symbol = defaults.string(forKey: symbolKey),
63+
!symbol.isEmpty
64+
else {
65+
return fallbackSymbol
66+
}
67+
return symbol
68+
}
5669
}

Bitkit/ViewModels/CurrencyViewModel.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ class CurrencyViewModel: ObservableObject {
2626
guard newValue != selectedCurrency else { return }
2727
objectWillChange.send()
2828
UserDefaults.standard.set(newValue, forKey: Self.selectedCurrencyKey)
29-
WeatherCurrencyAppGroupStore.save(code: newValue)
29+
syncDisplayCurrencyToAppGroup()
3030

3131
WeatherViewModel.shared.setCurrencyViewModel(self)
3232
WeatherViewModel.shared.handleCurrencyChange()
@@ -51,7 +51,7 @@ class CurrencyViewModel: ObservableObject {
5151
}
5252

5353
// Ensure the widget extension sees the current display currency on first launch.
54-
WeatherCurrencyAppGroupStore.save(code: selectedCurrency)
54+
syncDisplayCurrencyToAppGroup()
5555

5656
startPolling()
5757
}
@@ -74,6 +74,7 @@ class CurrencyViewModel: ObservableObject {
7474
lastSuccessfulRefresh = Date()
7575
error = nil
7676
hasStaleData = false
77+
syncDisplayCurrencyToAppGroup()
7778
} catch {
7879
self.error = error
7980
Logger.error(error, context: "Currency rates refresh failed")
@@ -117,6 +118,10 @@ class CurrencyViewModel: ObservableObject {
117118
func togglePrimaryDisplay() {
118119
primaryDisplay = primaryDisplay == .bitcoin ? .fiat : .bitcoin
119120
}
121+
122+
private func syncDisplayCurrencyToAppGroup() {
123+
WeatherCurrencyAppGroupStore.save(code: selectedCurrency, symbol: symbol)
124+
}
120125
}
121126

122127
// MARK: - UI Helpers
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
@testable import Bitkit
2+
import XCTest
3+
4+
/// Regression coverage for the shared fiat formatting helpers used by both the app and the
5+
/// WidgetKit extension, plus the App Group symbol sync. Guards against the home-screen weather
6+
/// widget rendering the locale-disambiguated "US$" instead of "$".
7+
final class FiatFormattingTests: XCTestCase {
8+
// MARK: - formatFiatAmount
9+
10+
func testFormatFiatAmount_TwoFractionDigits() throws {
11+
XCTAssertEqual(try formatFiatAmount(XCTUnwrap(Decimal(string: "0.5"))), "0.50")
12+
}
13+
14+
func testFormatFiatAmount_RoundsToTwoDigits() throws {
15+
XCTAssertEqual(try formatFiatAmount(XCTUnwrap(Decimal(string: "0.526"))), "0.53")
16+
}
17+
18+
func testFormatFiatAmount_GroupsThousandsWithComma() throws {
19+
XCTAssertEqual(try formatFiatAmount(XCTUnwrap(Decimal(string: "1234.5"))), "1,234.50")
20+
}
21+
22+
func testFormatFiatAmount_Zero() {
23+
XCTAssertEqual(formatFiatAmount(0), "0.00")
24+
}
25+
26+
// MARK: - formatFiatWithSymbol
27+
28+
func testFormatFiatWithSymbol_PrefixCurrency() {
29+
XCTAssertEqual(formatFiatWithSymbol(formatted: "0.52", symbol: "$", currencyCode: "USD"), "$0.52")
30+
}
31+
32+
func testFormatFiatWithSymbol_PrefixCurrency_WithSpace() {
33+
XCTAssertEqual(formatFiatWithSymbol(formatted: "0.52", symbol: "$", currencyCode: "USD", withSpace: true), "$ 0.52")
34+
}
35+
36+
func testFormatFiatWithSymbol_SuffixCurrency() {
37+
XCTAssertEqual(formatFiatWithSymbol(formatted: "0.52", symbol: "kr", currencyCode: "SEK"), "0.52kr")
38+
}
39+
40+
func testFormatFiatWithSymbol_SuffixCurrency_WithSpace() {
41+
XCTAssertEqual(formatFiatWithSymbol(formatted: "0.52", symbol: "kr", currencyCode: "SEK", withSpace: true), "0.52 kr")
42+
}
43+
44+
func testFormatFiatWithSymbol_PlaceholderDash() {
45+
XCTAssertEqual(formatFiatWithSymbol(formatted: "", symbol: "$", currencyCode: "USD", withSpace: true), "$ —")
46+
}
47+
48+
// MARK: - Regression guard: never "US$"
49+
50+
func testUsdFormatting_UsesDollarSignNotExtendedSymbol() throws {
51+
let amount = try formatFiatAmount(XCTUnwrap(Decimal(string: "0.52")))
52+
let result = formatFiatWithSymbol(formatted: amount, symbol: "$", currencyCode: "USD", withSpace: true)
53+
XCTAssertEqual(result, "$ 0.52")
54+
XCTAssertFalse(result.contains("US$"), "Weather widget must show \"$\", never the disambiguated \"US$\"")
55+
}
56+
57+
// MARK: - WeatherCurrencyAppGroupStore symbol sync
58+
59+
private static let suiteName = "group.bitkit"
60+
private static let codeKey = "home_screen_display_currency_code_v1"
61+
private static let symbolKey = "home_screen_display_currency_symbol_v1"
62+
63+
private var savedCode: String?
64+
private var savedSymbol: String?
65+
66+
override func setUp() {
67+
super.setUp()
68+
let defaults = UserDefaults(suiteName: Self.suiteName)
69+
savedCode = defaults?.string(forKey: Self.codeKey)
70+
savedSymbol = defaults?.string(forKey: Self.symbolKey)
71+
}
72+
73+
override func tearDown() {
74+
let defaults = UserDefaults(suiteName: Self.suiteName)
75+
if let savedCode { defaults?.set(savedCode, forKey: Self.codeKey) } else { defaults?.removeObject(forKey: Self.codeKey) }
76+
if let savedSymbol { defaults?.set(savedSymbol, forKey: Self.symbolKey) } else { defaults?.removeObject(forKey: Self.symbolKey) }
77+
super.tearDown()
78+
}
79+
80+
func testStore_RoundTripsCodeAndSymbol() {
81+
WeatherCurrencyAppGroupStore.save(code: "EUR", symbol: "")
82+
XCTAssertEqual(WeatherCurrencyAppGroupStore.load(), "EUR")
83+
XCTAssertEqual(WeatherCurrencyAppGroupStore.loadSymbol(), "")
84+
}
85+
86+
func testStore_LoadSymbolFallsBackOnEmpty() {
87+
WeatherCurrencyAppGroupStore.save(code: "USD", symbol: "")
88+
XCTAssertEqual(WeatherCurrencyAppGroupStore.loadSymbol(), WeatherCurrencyAppGroupStore.fallbackSymbol)
89+
XCTAssertEqual(WeatherCurrencyAppGroupStore.fallbackSymbol, "$")
90+
}
91+
92+
func testStore_PreservesBackendSymbolForUsd() {
93+
// The app syncs FxRate.currencySymbol ("$" for USD); the widget must read it back verbatim.
94+
WeatherCurrencyAppGroupStore.save(code: "USD", symbol: "$")
95+
XCTAssertEqual(WeatherCurrencyAppGroupStore.loadSymbol(), "$")
96+
}
97+
}

BitkitWidget/WeatherWidgetService.swift

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ enum WeatherWidgetService {
4141
let displayCurrency = WeatherCurrencyAppGroupStore.load()
4242
let displayRate = prices[displayCurrency] ?? usdRate
4343
let resolvedCurrency = prices[displayCurrency] != nil ? displayCurrency : WeatherCurrencyAppGroupStore.fallbackCode
44+
// Use the same backend symbol the app synced; the USD fallback path uses the "$" fallback.
45+
let resolvedSymbol = resolvedCurrency == displayCurrency
46+
? WeatherCurrencyAppGroupStore.loadSymbol()
47+
: WeatherCurrencyAppGroupStore.fallbackSymbol
4448

4549
let midSatsPerVbyte = Double(fees.halfHourFee)
4650
let medianFeeSats = fees.halfHourFee * Self.vbytesSize
@@ -55,7 +59,8 @@ enum WeatherWidgetService {
5559
let fiatString = formatFiat(
5660
sats: medianFeeSats,
5761
currencyPerBtc: displayRate,
58-
currencyCode: resolvedCurrency
62+
currencyCode: resolvedCurrency,
63+
symbol: resolvedSymbol
5964
)
6065

6166
let entry = CachedWeather(
@@ -89,21 +94,15 @@ enum WeatherWidgetService {
8994

9095
// MARK: - Formatting
9196

92-
/// Formats a satoshi amount in the user's selected display currency. Falls back to a "—"
93-
/// placeholder string formatted in the resolved currency when the rate is missing.
94-
private static func formatFiat(sats: Int, currencyPerBtc: Double?, currencyCode: String) -> String {
95-
let formatter = NumberFormatter()
96-
formatter.numberStyle = .currency
97-
formatter.currencyCode = currencyCode
98-
formatter.maximumFractionDigits = 2
99-
formatter.minimumFractionDigits = 2
100-
97+
/// Formats a satoshi amount in the user's selected display currency, reusing the in-app
98+
/// currency logic (`formatFiatAmount` + `formatFiatWithSymbol`) and the backend `symbol`
99+
/// synced through the App Group. Falls back to a "—" placeholder when the rate is missing.
100+
private static func formatFiat(sats: Int, currencyPerBtc: Double?, currencyCode: String, symbol: String) -> String {
101101
guard let currencyPerBtc, currencyPerBtc > 0 else {
102-
let symbol = formatter.currencySymbol ?? currencyCode
103-
return "\(symbol)"
102+
return formatFiatWithSymbol(formatted: "", symbol: symbol, currencyCode: currencyCode, withSpace: true)
104103
}
105104

106-
let amount = Double(sats) / 100_000_000.0 * currencyPerBtc
107-
return formatter.string(from: NSNumber(value: amount)) ?? String(format: "%.2f \(currencyCode)", amount)
105+
let amount = (Decimal(sats) / Decimal(100_000_000)) * Decimal(currencyPerBtc)
106+
return formatFiatWithSymbol(formatted: formatFiatAmount(amount), symbol: symbol, currencyCode: currencyCode, withSpace: true)
108107
}
109108
}

0 commit comments

Comments
 (0)