From 2a8409231d5612cf33d93d35341ccdd99390d6ab Mon Sep 17 00:00:00 2001 From: Edward Date: Fri, 19 Jun 2026 20:28:54 +0100 Subject: [PATCH 1/2] Add fiat amount entry to iMessage send --- macadamia.xcodeproj/project.pbxproj | 1 + macadamia/AppState.swift | 55 ++++------------- macadamia/Misc/Currency.swift | 59 ++++++++++++++++++- macadamia/Misc/NumericalInputView.swift | 2 +- .../Views/MessageAppUI.swift | 32 +++++----- 5 files changed, 84 insertions(+), 65 deletions(-) diff --git a/macadamia.xcodeproj/project.pbxproj b/macadamia.xcodeproj/project.pbxproj index 9d640cd..3547f91 100644 --- a/macadamia.xcodeproj/project.pbxproj +++ b/macadamia.xcodeproj/project.pbxproj @@ -116,6 +116,7 @@ AmountView.swift, Currency.swift, MintPicker.swift, + NumericalInputView.swift, TokenText.swift, ); target = 37C4CC2D2E5CA4660028E4FE /* macadamiaMessages */; diff --git a/macadamia/AppState.swift b/macadamia/AppState.swift index fa31ecd..987622e 100644 --- a/macadamia/AppState.swift +++ b/macadamia/AppState.swift @@ -18,14 +18,9 @@ class AppState: ObservableObject { @Published var pendingDeepLink: DeepLink? - private static let conversionUnitKey = "PreferredCurrencyConversionUnit" private static let lastRNackHashKey = "LastReleaseNotesAcknoledgedHash" private static let firstLaunchFlag = "HasLaunchedBefore" - struct ExchangeRateResponse: Decodable { - let bitcoin: ExchangeRate - } - static func showReleaseNotes() -> Bool { let releaseNotesSeenHash = UserDefaults.standard.string(forKey: AppState.lastRNackHashKey) if releaseNotesSeenHash ?? "not set" != ReleaseNote.hashString() { @@ -38,27 +33,9 @@ class AppState: ObservableObject { } } - struct ExchangeRate: Decodable, Equatable { - let rates: [String: Double] - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - rates = try container.decode([String: Double].self) - } - - // Regular initializer for mocking/testing - init(rates: [String: Double]) { - self.rates = rates - } - - func rate(for unit: Currency.Unit) -> Double? { - return rates[unit.currencyCode.lowercased()] - } - } - @Published var preferredConversionUnit: Currency.Unit { didSet { - UserDefaults.standard.setValue(preferredConversionUnit.currencyCode, forKey: AppState.conversionUnitKey) + Currency.Unit.savePreferred(preferredConversionUnit) } } @@ -70,13 +47,14 @@ class AppState: ObservableObject { } init() { - let candidate: Currency.Unit? = UserDefaults.standard - .string(forKey: AppState.conversionUnitKey) - .map { Currency.Unit(code: $0) } - if let candidate, candidate.kind == .fiat || candidate.kind == .none { + Currency.Unit.migratePreferredFromStandardDefaultsIfNeeded() + + let candidate = Currency.Unit.preferred + if candidate.kind == .fiat || candidate.kind == .none { preferredConversionUnit = candidate } else { preferredConversionUnit = .usd + Currency.Unit.savePreferred(.usd) } concealAmounts = AmountConcealment.userDefaults.bool(forKey: AmountConcealment.userDefaultsKey) @@ -91,7 +69,7 @@ class AppState: ObservableObject { // Provide mock exchange rates for previews if preferredUnit != .none { - self.exchangeRates = ExchangeRate(rates: [ + self.exchangeRates = Currency.ExchangeRate(rates: [ "usd": 95000.0, "eur": 87000.0, "gbp": 75000.0, @@ -104,7 +82,7 @@ class AppState: ObservableObject { // Don't call loadExchangeRates() for previews } - @Published var exchangeRates: ExchangeRate? + @Published var exchangeRates: Currency.ExchangeRate? func toggleConcealAmounts() { concealAmounts.toggle() @@ -112,24 +90,13 @@ class AppState: ObservableObject { func loadExchangeRates() { logger.info("loading exchange rates...") - - let currencies = Currency.Unit.fiatCases.map { $0.currencyCode.lowercased() }.joined(separator: ",") - guard let url = URL(string: "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=\(currencies)") else { - logger.warning("could not fetch exchange rates from API due to an invalid URL.") - return - } - + Task { - guard let (data, _) = try? await URLSession.shared.data(from: url) else { + guard let prices = await Currency.fetchBitcoinExchangeRates() else { logger.warning("unable to load conversion data.") return } - - guard let prices = try? JSONDecoder().decode(ExchangeRateResponse.self, from: data).bitcoin else { - logger.warning("unable to decode exchange rate data from request response.") - return - } - + await MainActor.run { self.exchangeRates = prices } diff --git a/macadamia/Misc/Currency.swift b/macadamia/Misc/Currency.swift index b7af400..03734be 100644 --- a/macadamia/Misc/Currency.swift +++ b/macadamia/Misc/Currency.swift @@ -8,6 +8,40 @@ import Foundation enum Currency { + struct ExchangeRate: Decodable, Equatable { + let rates: [String: Double] + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + rates = try container.decode([String: Double].self) + } + + init(rates: [String: Double]) { + self.rates = rates + } + + func rate(for unit: Currency.Unit) -> Double? { + rates[unit.currencyCode.lowercased()] + } + } + + private struct ExchangeRateResponse: Decodable { + let bitcoin: ExchangeRate + } + + static func fetchBitcoinExchangeRates() async -> ExchangeRate? { + let currencies = Unit.fiatCases.map { $0.currencyCode.lowercased() }.joined(separator: ",") + guard let url = URL(string: "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=\(currencies)") else { + return nil + } + + guard let (data, _) = try? await URLSession.shared.data(from: url) else { + return nil + } + + return try? JSONDecoder().decode(ExchangeRateResponse.self, from: data).bitcoin + } + struct Amount: Equatable { let absoluteValue: Double let unit: Unit @@ -305,14 +339,33 @@ enum Currency { try container.encode(currencyCode) } - /// Access the preferred conversion unit directly from UserDefaults without initializing AppState. + private static let appGroupID = "group.com.cypherbase.macadamia" + private static let preferredConversionUnitKey = "PreferredCurrencyConversionUnit" + + private static var sharedDefaults: UserDefaults { + UserDefaults(suiteName: appGroupID) ?? .standard + } + + /// Access the preferred conversion unit directly from shared UserDefaults without initializing AppState. static var preferred: Unit { - let key = "PreferredCurrencyConversionUnit" - if let code = UserDefaults.standard.string(forKey: key) { + if let code = sharedDefaults.string(forKey: preferredConversionUnitKey) { return Unit(code: code) } return .usd } + + static func savePreferred(_ unit: Unit) { + sharedDefaults.setValue(unit.currencyCode, forKey: preferredConversionUnitKey) + } + + static func migratePreferredFromStandardDefaultsIfNeeded() { + guard sharedDefaults.object(forKey: preferredConversionUnitKey) == nil, + let code = UserDefaults.standard.string(forKey: preferredConversionUnitKey) else { + return + } + + sharedDefaults.setValue(code, forKey: preferredConversionUnitKey) + } } } diff --git a/macadamia/Misc/NumericalInputView.swift b/macadamia/Misc/NumericalInputView.swift index b9d8b39..ccf54fd 100644 --- a/macadamia/Misc/NumericalInputView.swift +++ b/macadamia/Misc/NumericalInputView.swift @@ -18,7 +18,7 @@ struct NumericalInputView: View { let baseUnit: Currency.Unit /// Optional exchange rates - if nil, conversion features are disabled. - let exchangeRates: AppState.ExchangeRate? + let exchangeRates: Currency.ExchangeRate? let onReturn: () -> Void @State private var input: String = "" diff --git a/macadamia/macadamiaMessages/Views/MessageAppUI.swift b/macadamia/macadamiaMessages/Views/MessageAppUI.swift index 4b00653..9134463 100644 --- a/macadamia/macadamiaMessages/Views/MessageAppUI.swift +++ b/macadamia/macadamiaMessages/Views/MessageAppUI.swift @@ -208,22 +208,18 @@ struct MessageSendView: View { } @State private var memo: String = "" - @State private var amountString: String = "" + @State private var amount = 0 @State private var buttonState = ActionButtonState.idle("...") @State private var mintIcon: UIImage? @State private var selectedBanner: String = "" + @State private var exchangeRates: Currency.ExchangeRate? - @FocusState private var amountFieldInFocus @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 60 private var buttonDisabled: Bool { amount <= 0 || amount > mint.balance(for: .sat) } - - private var amount: Int { - Int(amountString) ?? 0 - } - + var body: some View { ZStack { List { @@ -267,14 +263,10 @@ struct MessageSendView: View { } Section { - HStack { - TextField("Enter amount...", text: $amountString) - .keyboardType(.numberPad) - .focused($amountFieldInFocus) - Spacer() - Text("sat") - } - .monospaced() + NumericalInputView(output: $amount, + baseUnit: .sat, + exchangeRates: exchangeRates, + onReturn: createToken) HStack { Text("Balance: ") @@ -321,13 +313,20 @@ struct MessageSendView: View { } .onAppear { buttonState = .idle(String(localized: "Send"), action: createToken) - amountFieldInFocus = true + } + .task { + guard exchangeRates == nil else { return } + exchangeRates = await Currency.fetchBitcoinExchangeRates() } .navigationTitle("Send") .navigationBarTitleDisplayMode(.inline) } private func createToken() { + guard !buttonDisabled else { + return + } + guard let activeWallet else { buttonState = .fail(String(localized: "No active wallet")) return @@ -337,7 +336,6 @@ struct MessageSendView: View { Task { do { - // iMessage extension is sat-only today. let token = try await AppSchemaV1.createToken(mint: mint, activeWallet: activeWallet, amount: amount, From da6c163226ebb62564c07301745930dadb9131d1 Mon Sep 17 00:00:00 2001 From: Edward Date: Fri, 19 Jun 2026 23:29:50 +0100 Subject: [PATCH 2/2] Avoid unused iMessage exchange-rate fetch --- macadamia/macadamiaMessages/Views/MessageAppUI.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macadamia/macadamiaMessages/Views/MessageAppUI.swift b/macadamia/macadamiaMessages/Views/MessageAppUI.swift index 9134463..a6825d6 100644 --- a/macadamia/macadamiaMessages/Views/MessageAppUI.swift +++ b/macadamia/macadamiaMessages/Views/MessageAppUI.swift @@ -316,6 +316,7 @@ struct MessageSendView: View { } .task { guard exchangeRates == nil else { return } + guard Currency.Unit.preferred.kind == .fiat else { return } exchangeRates = await Currency.fetchBitcoinExchangeRates() } .navigationTitle("Send")