From 74149df7d56c0480993d69615d2ff647f957274b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 25 May 2026 22:18:19 +0200 Subject: [PATCH 1/2] feat: calculator v61 redesign --- Bitkit/AppScene.swift | 2 + Bitkit/Components/Header.swift | 14 + Bitkit/Components/NumberPad.swift | 33 +- Bitkit/Components/TabBar/TabBar.swift | 7 +- Bitkit/Components/Widgets/BaseWidget.swift | 123 ++-- .../Components/Widgets/CalculatorWidget.swift | 691 +++++++++++------- Bitkit/MainNavView.swift | 2 + Bitkit/Managers/CalculatorInputManager.swift | 45 ++ Bitkit/Models/CalculatorWidgetData.swift | 405 ++++++++++ Bitkit/Models/Currency.swift | 2 +- .../CalculatorWidgetOptionsStore.swift | 21 + Bitkit/Views/Home/HomeWidgetsView.swift | 363 ++++++++- Bitkit/Views/HomeScreen.swift | 5 + .../Widgets/CalculatorWidgetPreviewView.swift | 226 ++++++ BitkitTests/CalculatorWidgetTests.swift | 185 +++++ changelog.d/next/554.changed.md | 1 + 16 files changed, 1773 insertions(+), 352 deletions(-) create mode 100644 Bitkit/Managers/CalculatorInputManager.swift create mode 100644 Bitkit/Models/CalculatorWidgetData.swift create mode 100644 Bitkit/Services/Widgets/CalculatorWidgetOptionsStore.swift create mode 100644 Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift create mode 100644 BitkitTests/CalculatorWidgetTests.swift create mode 100644 changelog.d/next/554.changed.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 7b5182f03..bd397cc0f 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -32,6 +32,7 @@ struct AppScene: View { @StateObject private var contactsManager = ContactsManager() @State private var keyboardManager = KeyboardManager() @State private var trezorViewModel = TrezorViewModel() + @State private var calculatorInputManager = CalculatorInputManager() @State private var hideSplash = false @State private var removeSplash = false @@ -148,6 +149,7 @@ struct AppScene: View { .environmentObject(contactsManager) .environment(keyboardManager) .environment(trezorViewModel) + .environment(calculatorInputManager) .onChange(of: pubkyProfile.authState, initial: true) { _, authState in if authState == .authenticated, let pk = pubkyProfile.publicKey { Task { diff --git a/Bitkit/Components/Header.swift b/Bitkit/Components/Header.swift index 1dfd44758..3c3dec200 100644 --- a/Bitkit/Components/Header.swift +++ b/Bitkit/Components/Header.swift @@ -1,6 +1,8 @@ import SwiftUI struct Header: View { + @Environment(CalculatorInputManager.self) private var calculatorInput + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false @EnvironmentObject var app: AppViewModel @@ -33,12 +35,14 @@ struct Header: View { AppStatus( testID: "HeaderAppStatus", onPress: { + if dismissCalculatorIfNeeded() { return } navigation.navigate(.appStatus) } ) if showWidgetEditButton { Button(action: { + if dismissCalculatorIfNeeded() { return } isEditingWidgets.toggle() }) { Image(isEditingWidgets ? "check-mark" : "pencil") @@ -53,6 +57,8 @@ struct Header: View { } Button { + if dismissCalculatorIfNeeded() { return } + withAnimation { app.showDrawer = true } @@ -75,6 +81,8 @@ struct Header: View { private var profileButton: some View { Button { + if dismissCalculatorIfNeeded() { return } + if pubkyProfile.isAuthenticated || pubkyProfile.cachedName != nil { navigation.navigate(.profile) } else if pubkyProfile.initializationErrorMessage != nil { @@ -103,6 +111,12 @@ struct Header: View { .accessibilityIdentifier("ProfileButton") } + private func dismissCalculatorIfNeeded() -> Bool { + guard calculatorInput.isPresented else { return false } + calculatorInput.dismiss() + return true + } + @ViewBuilder private var profileAvatar: some View { if let imageUri = pubkyProfile.displayImageUri { diff --git a/Bitkit/Components/NumberPad.swift b/Bitkit/Components/NumberPad.swift index 208f8f9bf..ce8eed69c 100644 --- a/Bitkit/Components/NumberPad.swift +++ b/Bitkit/Components/NumberPad.swift @@ -8,18 +8,38 @@ enum NumberPadType { struct NumberPad: View { let type: NumberPadType + let decimalSeparator: String let errorKey: String? + let onDeleteLongPress: (() -> Void)? let onPress: (String) -> Void - init(type: NumberPadType = .simple, errorKey: String? = nil, onPress: @escaping (String) -> Void) { + static var contentHeight: CGFloat { + buttonHeight * 4 + } + + private static var buttonHeight: CGFloat { + UIScreen.main.isSmall ? 65 : 44 + 34 + } + + init( + type: NumberPadType = .simple, + decimalSeparator: String = ".", + errorKey: String? = nil, + onDeleteLongPress: (() -> Void)? = nil, + onPress: @escaping (String) -> Void + ) { self.type = type + self.decimalSeparator = decimalSeparator self.errorKey = errorKey + self.onDeleteLongPress = onDeleteLongPress self.onPress = onPress } - private let buttonHeight: CGFloat = UIScreen.main.isSmall ? 65 : 44 + 34 private let gridItems = Array(repeating: GridItem(.flexible(), spacing: 0), count: 3) private let numbers = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] + private var buttonHeight: CGFloat { + Self.buttonHeight + } var body: some View { VStack(spacing: 0) { @@ -59,7 +79,7 @@ struct NumberPad: View { } case .decimal: NumberPadButton( - text: ".", + text: decimalSeparator, height: buttonHeight, hasError: errorKey == ".", testID: "NDecimal" @@ -98,6 +118,13 @@ struct NumberPad: View { .buttonStyle(NumberPadButtonStyle()) .accessibilityIdentifier("NRemove") .frame(maxWidth: .infinity) + .simultaneousGesture( + LongPressGesture(minimumDuration: 0.45).onEnded { _ in + guard let onDeleteLongPress else { return } + Haptics.play(.buttonTap) + onDeleteLongPress() + } + ) } } } diff --git a/Bitkit/Components/TabBar/TabBar.swift b/Bitkit/Components/TabBar/TabBar.swift index c1db33b37..899e93e5c 100644 --- a/Bitkit/Components/TabBar/TabBar.swift +++ b/Bitkit/Components/TabBar/TabBar.swift @@ -1,11 +1,14 @@ import SwiftUI struct TabBar: View { + @Environment(CalculatorInputManager.self) private var calculatorInput @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel var shouldShow: Bool { + if calculatorInput.isPresented { return false } + let routesWithTabBar = Set([.activityList, .savingsWallet, .spendingWallet]) if navigation.path.isEmpty { return true } return navigation.currentRoute.map { routesWithTabBar.contains($0) } ?? false @@ -34,7 +37,7 @@ struct TabBar: View { .transition(.move(edge: .bottom)) } } - .animation(.easeInOut, value: shouldShow) + .animation(.easeOut(duration: 0.14), value: shouldShow) .bottomSafeAreaPadding() } @@ -66,6 +69,7 @@ struct TabBar: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay { TabBar() + .environment(CalculatorInputManager()) .environmentObject(NavigationViewModel()) .environmentObject(SheetViewModel()) } @@ -79,6 +83,7 @@ struct TabBar: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay { TabBar() + .environment(CalculatorInputManager()) .environmentObject(NavigationViewModel()) .environmentObject(SheetViewModel()) } diff --git a/Bitkit/Components/Widgets/BaseWidget.swift b/Bitkit/Components/Widgets/BaseWidget.swift index 8d227a48a..09446fd3f 100644 --- a/Bitkit/Components/Widgets/BaseWidget.swift +++ b/Bitkit/Components/Widgets/BaseWidget.swift @@ -124,77 +124,74 @@ struct BaseWidget: View { } var body: some View { - Button {} label: { - VStack(spacing: 0) { - if isEditing { - HStack { - HStack(spacing: 16) { - Image(metadata.icon) - .resizable() - .frame(width: 32, height: 32) + VStack(spacing: 0) { + if isEditing { + HStack { + HStack(spacing: 16) { + Image(metadata.icon) + .resizable() + .frame(width: 32, height: 32) + + BodyMSBText(truncate(metadata.name, 18)) + .lineLimit(1) + } - BodyMSBText(truncate(metadata.name, 18)) - .lineLimit(1) - } + Spacer() - Spacer() - - // Action buttons when in edit mode - if isEditing { - HStack(spacing: 8) { - // Delete button - Button { - onDelete() - } label: { - Image("trash") - .resizable() - .foregroundColor(.textPrimary) - .frame(width: 24, height: 24) - } - .frame(width: 32, height: 32) - .contentShape(Rectangle()) - .accessibilityIdentifier("\(metadata.name)_WidgetActionDelete") - - // Edit button - Button { - onEdit() - } label: { - Image("gear-six") - .resizable() - .foregroundColor(.textPrimary) - .frame(width: 24, height: 24) - } - .frame(width: 32, height: 32) - .contentShape(Rectangle()) - .accessibilityIdentifier("\(metadata.name)_WidgetActionEdit") - - Image("burger") + // Action buttons when in edit mode + if isEditing { + HStack(spacing: 8) { + // Delete button + Button { + onDelete() + } label: { + Image("trash") .resizable() .foregroundColor(.textPrimary) .frame(width: 24, height: 24) - .frame(width: 32, height: 32) - .contentShape(Rectangle()) - .overlay { - Color.clear - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - .trackDragHandle() - } - .accessibilityIdentifier("\(metadata.name)_WidgetActionReorder") } + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + .accessibilityIdentifier("\(metadata.name)_WidgetActionDelete") + + // Edit button + Button { + onEdit() + } label: { + Image("gear-six") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + } + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + .accessibilityIdentifier("\(metadata.name)_WidgetActionEdit") + + Image("burger") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + .overlay { + Color.clear + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + .trackDragHandle() + } + .accessibilityIdentifier("\(metadata.name)_WidgetActionReorder") } } } + } - // Widget content (only shown when not editing) - if !isEditing { - content - } + // Widget content (only shown when not editing) + if !isEditing { + content } - .contentShape(Rectangle()) } - .accessibilityIdentifier("\(type.rawValue.capitalized)Widget") - .buttonStyle(WidgetButtonStyle()) + .contentShape(Rectangle()) + .accessibilityIdentifierIfPresent(isEditing ? nil : "\(type.rawValue.capitalized)Widget") .frame(maxWidth: .infinity) .padding((hasBackground || isEditing) ? 16 : 0) .background((hasBackground || isEditing) ? Color.gray6 : Color.clear) @@ -229,14 +226,6 @@ struct BaseWidget: View { } } -/// Custom button style for widgets -struct WidgetButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .opacity(configuration.isPressed ? 0.9 : 1.0) - } -} - // Preview for the BaseWidget #Preview { VStack { diff --git a/Bitkit/Components/Widgets/CalculatorWidget.swift b/Bitkit/Components/Widgets/CalculatorWidget.swift index 1e50d0719..e82edf10c 100644 --- a/Bitkit/Components/Widgets/CalculatorWidget.swift +++ b/Bitkit/Components/Widgets/CalculatorWidget.swift @@ -1,65 +1,26 @@ import SwiftUI -private let MAX_BITCOIN: UInt64 = 2_100_000_000_000_000 - -/// A reusable input row component for currency conversion -struct CurrencyInputRow: View { - let icon: CircularIcon - let placeholder: String = "0" - @Binding var text: String - let keyboardType: UIKeyboardType - let label: String - let isFocused: Bool - let onTextChange: (String) -> Void - - @EnvironmentObject private var currency: CurrencyViewModel - - var body: some View { - HStack(spacing: 0) { - icon - - SwiftUI.TextField(placeholder, text: $text) - .keyboardType(keyboardType) - .font(.custom(Fonts.semiBold, size: 15)) - .foregroundColor(.textPrimary) - .frame(maxWidth: .infinity) - .padding(.leading, 8) - .onChange(of: text) { _, newValue in onTextChange(newValue) } - - CaptionBText(label, textColor: .textSecondary) - .textCase(.uppercase) - } - .padding(16) - .background(Color.black) - .cornerRadius(8) - } -} - -/// A widget that provides Bitcoin to fiat currency conversion +/// A widget that provides Bitcoin to fiat currency conversion. struct CalculatorWidget: View { - /// Flag indicating if the widget is in editing mode var isEditing: Bool = false - - /// Callback to signal when editing should end var onEditingEnd: (() -> Void)? - /// Currency view model for currency conversion + @Environment(CalculatorInputManager.self) private var calculatorInput @EnvironmentObject private var currency: CurrencyViewModel - /// Bitcoin amount state (stored as string to preserve user input) - @State private var bitcoinAmount: String = "10000" - - /// Fiat amount state (stored as string to preserve user input) - @State private var fiatAmount: String = "" - - /// Focus state for text fields - @FocusState private var focusedField: FocusedField? - - private enum FocusedField { - case bitcoin, fiat + @State private var values = CalculatorWidgetValues() + @State private var hasHydrated = false + @State private var isNumberPadMounted = false + @State private var numberPadHeight: CGFloat = Self.collapsedNumberPadHeight + @State private var previousDisplayUnit: BitcoinDisplayUnit = .modern + + private static let numberPadDismissAnimation = Animation.easeOut(duration: numberPadDismissAnimationDuration) + private static let numberPadDismissAnimationDuration = 0.14 + private static let collapsedNumberPadHeight = 1 / UIScreen.main.scale + private static var fullNumberPadHeight: CGFloat { + 8 + NumberPad.contentHeight + (windowSafeAreaInsets.bottom > 0 ? windowSafeAreaInsets.bottom : 16) } - /// Initialize the widget init( isEditing: Bool = false, onEditingEnd: (() -> Void)? = nil @@ -69,271 +30,501 @@ struct CalculatorWidget: View { } var body: some View { - BaseWidget( - type: .calculator, - isEditing: isEditing, - onEditingEnd: onEditingEnd - ) { - VStack(spacing: 16) { - CurrencyInputRow( - icon: CircularIcon( - icon: "b-unit", - iconColor: .brandAccent, - backgroundColor: .gray6, - size: 32 - ), - text: $bitcoinAmount, - keyboardType: .numberPad, - label: "Bitcoin", - isFocused: focusedField == .bitcoin, - onTextChange: { newValue in - // Validate and filter input in real-time - let validatedValue = validateBitcoinInput(newValue) - if validatedValue != newValue { - bitcoinAmount = validatedValue - } - - if focusedField == .bitcoin { - updateFiatAmount(from: validatedValue) - } - } - ) - .focused($focusedField, equals: .bitcoin) - - CurrencyInputRow( - icon: CircularIcon( - icon: BodyMSBText(currency.symbol.count > 2 ? String(currency.symbol.prefix(1)) : currency.symbol, textColor: .brandAccent), - backgroundColor: .gray6, - size: 32 - ), - text: $fiatAmount, - keyboardType: .decimalPad, - label: currency.selectedCurrency, - isFocused: focusedField == .fiat, - onTextChange: { newValue in - // Validate and filter input in real-time - let validatedValue = validateFiatInput(newValue) - if validatedValue != newValue { - fiatAmount = validatedValue - } - - if focusedField == .fiat { - updateBitcoinAmount(from: validatedValue) - } - } + VStack(spacing: 0) { + BaseWidget( + type: .calculator, + isEditing: isEditing, + onEditingEnd: onEditingEnd + ) { + CalculatorWidgetWideContent( + values: currentValues, + activeInput: calculatorInput.activeInput, + onSelectInput: selectInput ) - .focused($focusedField, equals: .fiat) - .onSubmit { - // Format with trailing zeros when user finishes editing - fiatAmount = formatFiatInput(fiatAmount) - } - .onChange(of: focusedField) { _, newFocus in - // Format fiat amount when focus leaves the field - if newFocus != .fiat && !fiatAmount.isEmpty { - fiatAmount = formatFiatInput(fiatAmount) - } - } } - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button(t("common__done")) { - focusedField = nil - } - } + + if isNumberPadMounted { + numberPad + .frame(height: numberPadHeight, alignment: .top) + .clipped() + .allowsHitTesting(calculatorInput.isPresented) + .trackCalculatorNumberPadFrame() + .transition(.identity) } } + .animation(.easeOut(duration: 0.14), value: calculatorInput.isPresented) .onAppear { - // Initialize fiat amount on first load - if fiatAmount.isEmpty { - updateFiatAmount(from: bitcoinAmount) - } + updateNumberPadPresentation(isPresented: calculatorInput.isPresented, animated: false) + } + .onChange(of: calculatorInput.isPresented) { _, isPresented in + updateNumberPadPresentation(isPresented: isPresented, animated: true) + } + .task { + hydrateValuesIfNeeded() } .onChange(of: currency.selectedCurrency) { - // Update fiat amount when currency changes - updateFiatAmount(from: bitcoinAmount) + let previousValues = values + refreshCurrencyFields() + refreshDerivedValue(preferredSource: calculatorInput.activeInput) + refreshNumberPadConfiguration() + persistValuesIfNeeded(previousValues: previousValues) + } + .onChange(of: currency.displayUnit) { _, newUnit in + let previousValues = values + convertBitcoinValue(to: newUnit) + refreshCurrencyFields() + refreshDerivedValue(preferredSource: calculatorInput.activeInput) + refreshNumberPadConfiguration() + persistValuesIfNeeded(previousValues: previousValues) + } + .onChange(of: currency.rates) { + let previousValues = values + refreshCurrencyFields() + refreshDerivedValue(preferredSource: calculatorInput.activeInput) + persistValuesIfNeeded(previousValues: previousValues) + } + .onChange(of: calculatorInput.submittedKey?.id) { + guard let key = calculatorInput.submittedKey?.value else { return } + handleNumberPadInput(key) + } + } + + private var numberPad: some View { + VStack(spacing: 0) { + Spacer() + .frame(height: 8) + + VStack(spacing: 0) { + NumberPad( + type: calculatorInput.numberPadType, + decimalSeparator: calculatorInput.decimalSeparator, + errorKey: calculatorInput.errorKey, + onDeleteLongPress: { + calculatorInput.clear() + } + ) { key in + calculatorInput.submit(key) + } + .padding(.horizontal, 16) + } + .padding(.bottom, windowSafeAreaInsets.bottom > 0 ? windowSafeAreaInsets.bottom : 16) + .background(Color.black.ignoresSafeArea(edges: .bottom)) } } - /// Updates fiat amount based on bitcoin input - private func updateFiatAmount(from bitcoin: String) { - // Sanitize bitcoin input - let sanitizedBitcoin = sanitizeBitcoinInput(bitcoin) + private var currentValues: CalculatorWidgetValues { + CalculatorWidgetValues( + bitcoinValue: values.bitcoinValue, + fiatValue: values.fiatValue, + displayUnit: currency.displayUnit, + currencySymbol: currency.symbol, + selectedCurrency: currency.selectedCurrency + ) + } - guard let amount = UInt64(sanitizedBitcoin), amount > 0 else { - fiatAmount = "" + private func updateNumberPadPresentation(isPresented: Bool, animated: Bool) { + if isPresented { + var transaction = Transaction() + transaction.disablesAnimations = true + + withTransaction(transaction) { + isNumberPadMounted = true + numberPadHeight = Self.fullNumberPadHeight + } return } - // Cap the amount at maximum bitcoin - let cappedAmount = min(amount, MAX_BITCOIN) + let collapse = { + numberPadHeight = Self.collapsedNumberPadHeight + } - // Convert to fiat - if let converted = currency.convert(sats: cappedAmount) { - fiatAmount = formatFiatAmount(converted.value) + if animated { + withAnimation(Self.numberPadDismissAnimation, collapse) } else { - fiatAmount = "" + collapse() } - // Update bitcoin amount if it was capped or needs formatting - let formattedBitcoin = formatNumberWithSeparators(String(cappedAmount)) - if formattedBitcoin != bitcoin { - bitcoinAmount = formattedBitcoin + DispatchQueue.main.asyncAfter(deadline: .now() + Self.numberPadDismissAnimationDuration) { + guard !calculatorInput.isPresented else { return } + isNumberPadMounted = false } } - /// Updates bitcoin amount based on fiat input - private func updateBitcoinAmount(from fiat: String) { - // Sanitize fiat input - let sanitizedFiat = sanitizeFiatInput(fiat) + private func hydrateValuesIfNeeded() { + guard !hasHydrated else { return } + hasHydrated = true + + let saved = CalculatorWidgetOptionsStore.load() + let savedSats = CalculatorWidgetFormatter.bitcoinValueToSats(saved.bitcoinValue, displayUnit: saved.displayUnit) + + values = CalculatorWidgetValues( + bitcoinValue: saved.bitcoinValue.isEmpty + ? "" + : CalculatorWidgetFormatter.satsToBitcoinValue(savedSats, displayUnit: currency.displayUnit), + fiatValue: saved.fiatValue, + displayUnit: currency.displayUnit, + currencySymbol: currency.symbol, + selectedCurrency: currency.selectedCurrency + ) + previousDisplayUnit = currency.displayUnit + + refreshDerivedValue() + persistValues() + } + + private func selectInput(_ input: CalculatorMoneyType) { + calculatorInput.activate( + input, + numberPadType: numberPadType(for: input), + decimalSeparator: CalculatorWidgetFormatter.numberPadDecimalSeparator() + ) + } + + private func handleNumberPadInput(_ key: String) { + guard let activeInput = calculatorInput.activeInput else { return } - guard let amount = Double(sanitizedFiat), amount > 0 else { - bitcoinAmount = "" + let currentValue = rawValue(for: activeInput) + let nextValue = CalculatorWidgetFormatter.applyNumberPadInput( + rawValue: currentValue, + key: key, + maxDecimalPlaces: maxDecimalPlaces(for: activeInput) + ) + + guard nextValue != currentValue || key == "delete" || key == "clear" else { + showInputError(for: key) return } - // Convert to sats - if let convertedSats = currency.convert(fiatAmount: amount) { - // Cap the amount at maximum bitcoin - let cappedSats = min(convertedSats, MAX_BITCOIN) + if activeInput == .bitcoin, + CalculatorWidgetFormatter.exceedsMaxBitcoin(nextValue, displayUnit: currency.displayUnit) + { + showInputError(for: key) + return + } - bitcoinAmount = formatNumberWithSeparators(String(cappedSats)) + calculatorInput.errorKey = nil - // Update fiat amount if bitcoin was capped - if cappedSats != convertedSats { - if let converted = currency.convert(sats: cappedSats) { - fiatAmount = formatFiatAmount(converted.value) - } - } - } else { - bitcoinAmount = "" + switch activeInput { + case .bitcoin: + values.bitcoinValue = nextValue + refreshFiatFromBitcoin() + case .fiat: + values.fiatValue = nextValue + refreshBitcoinFromFiat() } - } - /// Sanitizes bitcoin input by removing non-numeric characters and leading zeros - private func sanitizeBitcoinInput(_ input: String) -> String { - let cleaned = input.replacingOccurrences(of: " ", with: "") - return cleaned.replacingOccurrences(of: "^0+(?=\\d)", with: "", options: .regularExpression) + persistValues() } - /// Sanitizes fiat input by handling decimal points and limiting decimal places - private func sanitizeFiatInput(_ input: String) -> String { - let processed = - input - .replacingOccurrences(of: ",", with: ".") - .replacingOccurrences(of: " ", with: "") + private func rawValue(for input: CalculatorMoneyType) -> String { + switch input { + case .bitcoin: + return values.bitcoinValue + case .fiat: + return values.fiatValue + } + } - let components = processed.components(separatedBy: ".") - if components.count > 2 { - // Only keep first decimal point - return components[0] + "." + components[1] + private func numberPadType(for input: CalculatorMoneyType) -> NumberPadType { + switch input { + case .bitcoin where currency.displayUnit == .modern: + return .integer + default: + return .decimal } + } - if components.count == 2 { - let integer = components[0].replacingOccurrences(of: "^0+(?=\\d)", with: "", options: .regularExpression) - let decimal = String(components[1].prefix(2)) // Limit to 2 decimal places - return (integer.isEmpty ? "0" : integer) + "." + decimal + private func maxDecimalPlaces(for input: CalculatorMoneyType) -> Int? { + switch input { + case .bitcoin where currency.displayUnit == .modern: + return nil + case .bitcoin: + return CalculatorWidgetFormatter.classicBitcoinDecimalPlaces + case .fiat: + return CalculatorWidgetFormatter.fiatDecimalPlaces } + } - return processed.replacingOccurrences(of: "^0+(?=\\d)", with: "", options: .regularExpression) + private func refreshNumberPadConfiguration() { + guard let activeInput = calculatorInput.activeInput else { return } + calculatorInput.updateConfiguration( + numberPadType: numberPadType(for: activeInput), + decimalSeparator: CalculatorWidgetFormatter.numberPadDecimalSeparator() + ) } - /// Formats a number with space separators for thousands - private func formatNumberWithSeparators(_ value: String) -> String { - let endsWithDecimal = value.hasSuffix(".") - let cleanNumber = value.replacingOccurrences(of: "[^\\d.]", with: "", options: .regularExpression) - let components = cleanNumber.components(separatedBy: ".") + private func refreshCurrencyFields() { + values.displayUnit = currency.displayUnit + values.currencySymbol = currency.symbol + values.selectedCurrency = currency.selectedCurrency + } - let integer = components[0] - let formattedInteger = integer.replacingOccurrences(of: "\\B(?=(\\d{3})+(?!\\d))", with: " ", options: .regularExpression) + private func convertBitcoinValue(to newUnit: BitcoinDisplayUnit) { + guard previousDisplayUnit != newUnit else { return } - if components.count > 1 { - return formattedInteger + "." + components[1] - } + let sats = CalculatorWidgetFormatter.bitcoinValueToSats(values.bitcoinValue, displayUnit: previousDisplayUnit) + values.bitcoinValue = CalculatorWidgetFormatter.satsToBitcoinValue(sats, displayUnit: newUnit) + previousDisplayUnit = newUnit + } + + private func refreshDerivedValue(preferredSource: CalculatorMoneyType? = nil) { + guard let source = values.refreshSource(activeInput: preferredSource) else { return } - return endsWithDecimal ? formattedInteger + "." : formattedInteger + if source == .fiat { + refreshBitcoinFromFiat(preserveBitcoinOnConversionFailure: true) + } else { + refreshFiatFromBitcoin() + } } - /// Formats fiat amount to string with proper decimal handling - private func formatFiatAmount(_ value: Decimal) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.minimumFractionDigits = 2 // Always show 2 decimal places - formatter.maximumFractionDigits = 2 - formatter.groupingSeparator = " " + private func refreshFiatFromBitcoin() { + guard !values.bitcoinValue.isEmpty else { + values.fiatValue = "" + return + } + + let sats = CalculatorWidgetFormatter.bitcoinValueToSats(values.bitcoinValue, displayUnit: currency.displayUnit) + if sats == 0 { + values.fiatValue = "0.00" + return + } - return formatter.string(from: value as NSDecimalNumber) ?? "0.00" + if let converted = currency.convert(sats: sats) { + values.fiatValue = CalculatorWidgetFormatter.fiatRawValue(from: converted.value) + } else { + values.fiatValue = "" + } } - /// Formats user input to always show 2 decimal places when it contains a decimal - private func formatFiatInput(_ input: String) -> String { - // Don't format if empty or just a dot - if input.isEmpty || input == "." { - return input + private func refreshBitcoinFromFiat(preserveBitcoinOnConversionFailure: Bool = false) { + guard !values.fiatValue.isEmpty else { + values.bitcoinValue = "" + return } - // If it contains a decimal point, ensure 2 decimal places - if input.contains(".") { - let components = input.components(separatedBy: ".") - if components.count == 2 { - let integer = components[0] - let decimal = components[1] + if let sats = CalculatorWidgetFormatter.convertedSatsFromFiat(values.fiatValue, convert: { currency.convert(fiatAmount: $0) }) { + let cappedSats = min(sats, CalculatorWidgetFormatter.maxBitcoinSats) + values.bitcoinValue = CalculatorWidgetFormatter.fiatConversionBitcoinValue(cappedSats, displayUnit: currency.displayUnit) - // Pad decimal part to 2 digits - let paddedDecimal = decimal.padding(toLength: 2, withPad: "0", startingAt: 0) - return integer + "." + paddedDecimal + if cappedSats != sats { + if let cappedFiat = currency.convert(sats: cappedSats) { + values.fiatValue = CalculatorWidgetFormatter.fiatRawValue(from: cappedFiat.value) + } else { + values.fiatValue = "" + } } + return } - return input + guard !preserveBitcoinOnConversionFailure else { return } + values.bitcoinValue = "" } - /// Validates fiat input to ensure only numbers and up to 2 decimal places - private func validateFiatInput(_ input: String) -> String { - // Convert comma to dot and remove spaces - let processed = - input - .replacingOccurrences(of: ",", with: ".") - .replacingOccurrences(of: " ", with: "") + private func persistValues() { + guard hasHydrated else { return } + CalculatorWidgetOptionsStore.save(currentValues) + } - // Check if input matches valid pattern: digits, optional dot, up to 2 decimal digits - let validPattern = "^\\d*\\.?\\d{0,2}$" + private func persistValuesIfNeeded(previousValues: CalculatorWidgetValues) { + guard values != previousValues else { return } + persistValues() + } - // Allow empty string, single dot, or "0." - if processed.isEmpty || processed == "." || processed == "0." { - return processed + private func showInputError(for key: String) { + Haptics.notify(.warning) + calculatorInput.errorKey = key + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if calculatorInput.errorKey == key { + calculatorInput.errorKey = nil + } } + } +} + +// MARK: - Wide layout (in-app + carousel page) + +struct CalculatorWidgetWideContent: View { + let values: CalculatorWidgetValues + let activeInput: CalculatorMoneyType? + let onSelectInput: (CalculatorMoneyType) -> Void + + var body: some View { + VStack(spacing: 16) { + CalculatorWidgetRow( + currencySymbol: "₿", + value: CalculatorWidgetFormatter.formatBitcoinValue(values.bitcoinValue, displayUnit: values.displayUnit), + placeholder: CalculatorWidgetFormatter.formatBitcoinPlaceholder(values.bitcoinValue, displayUnit: values.displayUnit), + label: t("settings__general__unit_bitcoin"), + iconSize: 32, + rowPadding: 16, + showsLabel: true, + isActive: activeInput == .bitcoin, + accessibilityIdentifier: "CalculatorBtcInput" + ) { + onSelectInput(.bitcoin) + } - // Test against the pattern - if processed.range(of: validPattern, options: .regularExpression) != nil { - // Remove leading zeros except before decimal or if it's just "0" - if processed.hasPrefix("0") && processed.count > 1 && !processed.hasPrefix("0.") { - let withoutLeadingZeros = processed.replacingOccurrences(of: "^0+", with: "", options: .regularExpression) - return withoutLeadingZeros.isEmpty ? "0" : withoutLeadingZeros + CalculatorWidgetRow( + currencySymbol: values.currencySymbol, + value: CalculatorWidgetFormatter.formatFiatValue(values.fiatValue), + placeholder: CalculatorWidgetFormatter.formatFiatPlaceholder(values.fiatValue), + label: values.selectedCurrency, + iconSize: 32, + rowPadding: 16, + showsLabel: true, + isActive: activeInput == .fiat, + accessibilityIdentifier: "CalculatorFiatInput" + ) { + onSelectInput(.fiat) } - return processed } + } +} - // If invalid, return the previous valid value by removing the last character - return String(processed.dropLast()) +// MARK: - Compact layout (small carousel page) + +struct CalculatorWidgetCompactContent: View { + let values: CalculatorWidgetValues + + var body: some View { + VStack(spacing: 16) { + CalculatorWidgetRow( + currencySymbol: "₿", + value: CalculatorWidgetFormatter.formatBitcoinValue(values.bitcoinValue, displayUnit: values.displayUnit), + iconSize: 24, + rowPadding: 12, + showsLabel: false, + isActive: false + ) + + CalculatorWidgetRow( + currencySymbol: values.currencySymbol, + value: CalculatorWidgetFormatter.formatFiatValue(values.fiatValue), + iconSize: 24, + rowPadding: 12, + showsLabel: false, + isActive: false + ) + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray6) + .cornerRadius(16) } +} + +private struct CalculatorWidgetRow: View { + let currencySymbol: String + let value: String + var placeholder: String = "" + var label: String? + let iconSize: CGFloat + let rowPadding: CGFloat + let showsLabel: Bool + let isActive: Bool + var accessibilityIdentifier: String? + var onTap: (() -> Void)? - /// Validates bitcoin input to ensure only numbers and spaces - private func validateBitcoinInput(_ input: String) -> String { - // Allow empty input - if input.isEmpty { - return input + var body: some View { + if let onTap { + Button(action: onTap) { + rowContent + } + .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityIdentifier ?? "") + } else { + rowContent } + } + + private var rowContent: some View { + HStack(alignment: .center, spacing: 8) { + ZStack { + Circle() + .fill(Color.gray6) + + Text(CalculatorWidgetFormatter.displaySymbol(currencySymbol)) + .font(Fonts.semiBold(size: iconSize >= 32 ? 17 : 15)) + .foregroundColor(.brandAccent) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(width: iconSize, height: iconSize) + + HStack(spacing: 0) { + Text(displayValue) + .font(Fonts.semiBold(size: 17)) + .foregroundColor(value.isEmpty ? .white50 : .textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) + + if isActive { + CalculatorCursor() + .frame(width: 0) + .offset(x: -1) + } + + if !placeholder.isEmpty { + Text(placeholder) + .font(Fonts.semiBold(size: 17)) + .foregroundColor(.white50) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .clipped() + + if showsLabel, let label { + CaptionBText(label.uppercased(), textColor: .textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + .padding(rowPadding) + .frame(maxWidth: .infinity) + .background(Color.black) + .cornerRadius(8) + .contentShape(Rectangle()) + } - // Only allow digits and spaces - let validPattern = "^[\\d\\s]+$" + private var displayValue: String { + value.isEmpty ? "0" : value + } +} - if input.range(of: validPattern, options: .regularExpression) != nil { - return input +private struct CalculatorCursor: View { + var body: some View { + TimelineView(.periodic(from: .now, by: 0.5)) { context in + Rectangle() + .fill(isVisible(at: context.date) ? Color.brandAccent : Color.clear) + .frame(width: 2, height: 22) } + .frame(width: 2, height: 22) + } + + private func isVisible(at date: Date) -> Bool { + Int(date.timeIntervalSince1970 * 2) % 2 == 0 + } +} - // If invalid, return the previous valid value by removing the last character - return String(input.dropLast()) +struct CalculatorNumberPadFramePreferenceKey: PreferenceKey { + static var defaultValue: CGRect? + + static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) { + value = nextValue() ?? value + } +} + +private extension View { + func trackCalculatorNumberPadFrame() -> some View { + background { + GeometryReader { proxy in + Color.clear.preference( + key: CalculatorNumberPadFramePreferenceKey.self, + value: proxy.frame(in: .global) + ) + } + } } } @@ -341,6 +532,7 @@ struct CalculatorWidget: View { CalculatorWidget() .padding() .background(Color.black) + .environment(CalculatorInputManager()) .environmentObject(CurrencyViewModel()) .preferredColorScheme(.dark) } @@ -349,6 +541,7 @@ struct CalculatorWidget: View { CalculatorWidget(isEditing: true) .padding() .background(Color.black) + .environment(CalculatorInputManager()) .environmentObject(CurrencyViewModel()) .preferredColorScheme(.dark) } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index c28bd35b8..a89f282a8 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -484,6 +484,8 @@ struct MainNavView: View { FactsWidgetPreviewView() case .weather: WeatherWidgetPreviewView() + case .calculator: + CalculatorWidgetPreviewView() default: WidgetDetailView(id: widgetType) } diff --git a/Bitkit/Managers/CalculatorInputManager.swift b/Bitkit/Managers/CalculatorInputManager.swift new file mode 100644 index 000000000..7c0d66a86 --- /dev/null +++ b/Bitkit/Managers/CalculatorInputManager.swift @@ -0,0 +1,45 @@ +import Foundation + +@Observable +final class CalculatorInputManager { + struct SubmittedKey: Equatable { + let id = UUID() + let value: String + } + + var activeInput: CalculatorMoneyType? + var numberPadType: NumberPadType = .integer + var decimalSeparator = "." + var errorKey: String? + var submittedKey: SubmittedKey? + + var isPresented: Bool { + activeInput != nil + } + + func activate(_ input: CalculatorMoneyType, numberPadType: NumberPadType, decimalSeparator: String) { + activeInput = input + self.numberPadType = numberPadType + self.decimalSeparator = decimalSeparator + errorKey = nil + } + + func updateConfiguration(numberPadType: NumberPadType, decimalSeparator: String) { + self.numberPadType = numberPadType + self.decimalSeparator = decimalSeparator + } + + func submit(_ key: String) { + submittedKey = SubmittedKey(value: key) + } + + func clear() { + submittedKey = SubmittedKey(value: "clear") + } + + func dismiss() { + activeInput = nil + errorKey = nil + submittedKey = nil + } +} diff --git a/Bitkit/Models/CalculatorWidgetData.swift b/Bitkit/Models/CalculatorWidgetData.swift new file mode 100644 index 000000000..bfbecc1b2 --- /dev/null +++ b/Bitkit/Models/CalculatorWidgetData.swift @@ -0,0 +1,405 @@ +import Foundation + +struct CalculatorWidgetValues: Codable, Equatable { + var bitcoinValue: String + var fiatValue: String + var displayUnit: BitcoinDisplayUnit + var currencySymbol: String + var selectedCurrency: String + + init( + bitcoinValue: String = "10000", + fiatValue: String = "", + displayUnit: BitcoinDisplayUnit = .modern, + currencySymbol: String = "$", + selectedCurrency: String = "USD" + ) { + self.bitcoinValue = bitcoinValue + self.fiatValue = fiatValue + self.displayUnit = displayUnit + self.currencySymbol = currencySymbol + self.selectedCurrency = selectedCurrency + } + + var shouldRefreshBitcoinFromFiat: Bool { + bitcoinValue.isEmpty && !fiatValue.isEmpty + } + + func refreshSource(activeInput: CalculatorMoneyType?) -> CalculatorMoneyType? { + if activeInput == .fiat, fiatValue.isEmpty { return nil } + if let activeInput { return activeInput } + return shouldRefreshBitcoinFromFiat ? .fiat : .bitcoin + } +} + +enum CalculatorMoneyType: Equatable { + case bitcoin + case fiat +} + +enum CalculatorWidgetFormatter { + static let fiatDecimalPlaces = 2 + static let classicBitcoinDecimalPlaces = 8 + static let maxBitcoinSats: UInt64 = 2_100_000_000_000_000 + + private static let groupSize = 3 + private static let commaSeparator: Character = "," + private static let periodSeparator: Character = "." + private static let satsGroupingSeparator: Character = " " + private static let fiatGroupingSeparator: Character = "," + private static let displayDecimalSeparator: Character = "." + private static let posixLocale = Locale(identifier: "en_US_POSIX") + + static func displaySymbol(_ symbol: String) -> String { + let trimmed = symbol.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.count >= 3 ? String(trimmed.prefix(1)) : trimmed + } + + static func decimalSeparator(locale: Locale = .current) -> String { + DecimalFormatSymbols.decimalSeparator(locale: locale) + } + + static func numberPadDecimalSeparator() -> String { + String(displayDecimalSeparator) + } + + static func formatBitcoinValue(_ rawValue: String, displayUnit: BitcoinDisplayUnit, locale: Locale = .current) -> String { + if rawValue.isEmpty { return "" } + + switch displayUnit { + case .modern: + return formatGroupedInteger( + value: rawValue.filter(\.isNumber), + groupingSeparator: satsGroupingSeparator + ) + case .classic: + return formatGroupedDecimal( + value: sanitizeDecimalInput(raw: rawValue, locale: locale, maxDecimalPlaces: classicBitcoinDecimalPlaces), + groupingSeparator: satsGroupingSeparator, + decimalSeparator: displayDecimalSeparator + ) + } + } + + static func formatBitcoinPlaceholder(_ rawValue: String, displayUnit: BitcoinDisplayUnit, locale: Locale = .current) -> String { + guard displayUnit == .classic else { return "" } + + let normalized = sanitizeDecimalInput(raw: rawValue, locale: locale, maxDecimalPlaces: classicBitcoinDecimalPlaces) + let zeroes = String(repeating: "0", count: classicBitcoinDecimalPlaces) + + guard normalized.contains(periodSeparator) else { + return String(displayDecimalSeparator) + zeroes + } + + let decimalLength = normalized.split(separator: periodSeparator, maxSplits: 1, omittingEmptySubsequences: false).dropFirst().first?.count ?? 0 + let remainingDecimals = classicBitcoinDecimalPlaces - decimalLength + return remainingDecimals > 0 ? String(repeating: "0", count: remainingDecimals) : "" + } + + static func formatFiatValue(_ rawValue: String, locale: Locale = .current) -> String { + if rawValue.isEmpty { return "" } + + let normalized = sanitizeDecimalInput( + raw: normalizeDecimalInput(rawValue, locale: locale, maxDecimalPlaces: fiatDecimalPlaces), + locale: locale, + maxDecimalPlaces: fiatDecimalPlaces + ) + + return formatGroupedDecimal( + value: normalized, + groupingSeparator: fiatGroupingSeparator, + decimalSeparator: displayDecimalSeparator + ) + } + + static func formatFiatPlaceholder(_ rawValue: String, locale: Locale = .current) -> String { + if rawValue.isEmpty { return "" } + + let normalized = sanitizeDecimalInput( + raw: normalizeDecimalInput(rawValue, locale: locale, maxDecimalPlaces: fiatDecimalPlaces), + locale: locale, + maxDecimalPlaces: fiatDecimalPlaces + ) + + guard normalized.contains(periodSeparator) else { return "" } + + let decimalLength = normalized.split(separator: periodSeparator, maxSplits: 1, omittingEmptySubsequences: false).dropFirst().first?.count ?? 0 + let remainingDecimals = fiatDecimalPlaces - decimalLength + return remainingDecimals > 0 ? String(repeating: "0", count: remainingDecimals) : "" + } + + static func applyNumberPadInput( + rawValue: String, + key: String, + maxDecimalPlaces: Int?, + locale: Locale = .current + ) -> String { + let normalizedRawValue: String = if let maxDecimalPlaces { + normalizeDecimalInput(rawValue, locale: locale, maxDecimalPlaces: maxDecimalPlaces) + } else { + rawValue + } + + let decimalKey = maxDecimalPlaces == nil ? "." : DecimalFormatSymbols.decimalSeparator(locale: locale) + let normalizedKey = key == decimalKey ? "." : key + + let nextValue: String = switch normalizedKey { + case "clear": + "" + case "delete": + String(normalizedRawValue.dropLast()) + case ".": + appendDecimalSeparator(normalizedRawValue, maxDecimalPlaces: maxDecimalPlaces) + case "000": + appendDigits("000", to: normalizedRawValue) + default: + if key.count == 1, key.first?.isNumber == true { + appendDigits(key, to: normalizedRawValue) + } else { + normalizedRawValue + } + } + + if maxDecimalPlaces == nil { + return sanitizeIntegerInput(nextValue) + } + + return sanitizeDecimalInput( + raw: nextValue, + locale: locale, + maxDecimalPlaces: maxDecimalPlaces + ) + } + + static func sanitizeIntegerInput(_ raw: String) -> String { + let digits = raw.filter(\.isNumber) + guard !digits.isEmpty else { return "" } + let trimmed = digits.drop { $0 == "0" } + return trimmed.isEmpty ? "0" : String(trimmed) + } + + static func sanitizeDecimalInput(raw: String, locale: Locale = .current, maxDecimalPlaces: Int? = nil) -> String { + let localDecimal = DecimalFormatSymbols.decimalSeparator(locale: locale) + let normalized = localDecimal == "," ? raw.replacingOccurrences(of: ",", with: ".") : raw + let filtered = normalized.filter { $0.isNumber || $0 == "." } + + guard let dotIndex = filtered.firstIndex(of: ".") else { + return filtered + } + + let prefix = filtered[...dotIndex] + let suffix = filtered[filtered.index(after: dotIndex)...].filter { $0 != "." } + let singleDot = String(prefix) + String(suffix) + + guard let maxDecimalPlaces else { return singleDot } + + let fraction = String(singleDot[singleDot.index(after: dotIndex)...]) + guard fraction.count > maxDecimalPlaces else { return singleDot } + + return String(singleDot[...dotIndex]) + String(fraction.prefix(maxDecimalPlaces)) + } + + static func bitcoinValueToSats(_ rawValue: String, displayUnit: BitcoinDisplayUnit) -> UInt64 { + let normalized = rawValue.replacingOccurrences(of: " ", with: "") + + switch displayUnit { + case .modern: + return min(UInt64(sanitizeIntegerInput(normalized)) ?? 0, maxBitcoinSats) + case .classic: + let decimal = decimalValue(sanitizeDecimalInput(raw: normalized, maxDecimalPlaces: classicBitcoinDecimalPlaces)) + let sats = decimal * Decimal(100_000_000) + return min(roundedUInt64(sats), maxBitcoinSats) + } + } + + static func satsToBitcoinValue(_ sats: UInt64, displayUnit: BitcoinDisplayUnit) -> String { + switch displayUnit { + case .modern: + return sats == 0 ? "" : String(sats) + case .classic: + guard sats > 0 else { return "" } + let btc = Decimal(sats) / Decimal(100_000_000) + return trimTrailingZeros(formatDecimal(btc, maximumFractionDigits: classicBitcoinDecimalPlaces)) + } + } + + static func fiatConversionBitcoinValue(_ sats: UInt64, displayUnit: BitcoinDisplayUnit) -> String { + sats == 0 ? "0" : satsToBitcoinValue(sats, displayUnit: displayUnit) + } + + static func convertedSatsFromFiat(_ rawValue: String, convert: (Double) -> UInt64?) -> UInt64? { + let fiatValue = fiatDecimalValue(rawValue) + if NSDecimalNumber(decimal: fiatValue).compare(NSDecimalNumber.zero) == .orderedSame { + return 0 + } + + return convert(NSDecimalNumber(decimal: fiatValue).doubleValue) + } + + static func fiatDecimalValue(_ rawValue: String) -> Decimal { + decimalValue(sanitizeDecimalInput(raw: rawValue, maxDecimalPlaces: fiatDecimalPlaces)) + } + + static func fiatRawValue(from value: Decimal) -> String { + formatDecimal(value, minimumFractionDigits: fiatDecimalPlaces, maximumFractionDigits: fiatDecimalPlaces) + } + + static func exceedsMaxBitcoin(_ rawValue: String, displayUnit: BitcoinDisplayUnit) -> Bool { + let normalized = rawValue.replacingOccurrences(of: " ", with: "") + + switch displayUnit { + case .modern: + guard let sats = UInt64(sanitizeIntegerInput(normalized)) else { + return !normalized.isEmpty + } + return sats > maxBitcoinSats + case .classic: + let btc = decimalValue(sanitizeDecimalInput(raw: normalized, maxDecimalPlaces: classicBitcoinDecimalPlaces)) + return NSDecimalNumber(decimal: btc).compare(NSDecimalNumber(value: 21_000_000)) == .orderedDescending + } + } + + private static func normalizeDecimalInput(_ rawValue: String, locale: Locale, maxDecimalPlaces: Int?) -> String { + let value = rawValue.replacingOccurrences(of: " ", with: "") + let hasComma = value.contains(commaSeparator) + let hasPeriod = value.contains(periodSeparator) + + if hasComma, hasPeriod { + return normalizeMixedDecimalSeparators(value) + } + + guard hasComma else { return value } + + if shouldTreatCommaAsGrouping(value, locale: locale, maxDecimalPlaces: maxDecimalPlaces) { + return value.replacingOccurrences(of: ",", with: "") + } + + return value.replacingOccurrences(of: ",", with: ".") + } + + private static func normalizeMixedDecimalSeparators(_ value: String) -> String { + let decimalSeparator: Character = (value.lastIndex(of: commaSeparator) ?? value.startIndex) > + (value.lastIndex(of: periodSeparator) ?? value.startIndex) + ? commaSeparator + : periodSeparator + let groupingSeparator = decimalSeparator == commaSeparator ? periodSeparator : commaSeparator + + return value + .replacingOccurrences(of: String(groupingSeparator), with: "") + .replacingOccurrences(of: String(decimalSeparator), with: ".") + } + + private static func shouldTreatCommaAsGrouping(_ value: String, locale: Locale, maxDecimalPlaces: Int?) -> Bool { + if value.filter({ $0 == commaSeparator }).count > 1 { return true } + + let separator = DecimalFormatSymbols.decimalSeparator(locale: locale) + if separator != "," { return true } + + let fractionLength = value.split(separator: commaSeparator, maxSplits: 1, omittingEmptySubsequences: false).dropFirst().first?.count ?? 0 + return maxDecimalPlaces != nil && fractionLength > maxDecimalPlaces! + } + + private static func formatGroupedInteger(value: String, groupingSeparator: Character) -> String { + guard !value.isEmpty else { return "" } + let normalized = value.drop { $0 == "0" } + let integer = normalized.isEmpty ? "0" : String(normalized) + return formatGroupedDigits(integer, groupingSeparator: groupingSeparator) + } + + private static func formatGroupedIntegerPreservingZeros(value: String, groupingSeparator: Character) -> String { + guard !value.isEmpty else { return "" } + return formatGroupedDigits(value, groupingSeparator: groupingSeparator) + } + + private static func formatGroupedDecimal(value: String, groupingSeparator: Character, decimalSeparator: Character) -> String { + guard !value.isEmpty else { return "" } + if value == "." { return String(decimalSeparator) } + + guard let decimalIndex = value.firstIndex(of: ".") else { + return formatGroupedIntegerPreservingZeros(value: value, groupingSeparator: groupingSeparator) + } + + let integerPart = String(value[.. String { + guard maxDecimalPlaces != nil, !rawValue.contains(".") else { return rawValue } + return rawValue.isEmpty ? "0." : "\(rawValue)." + } + + private static func appendDigits(_ digits: String, to rawValue: String) -> String { + guard rawValue == "0" else { return rawValue + digits } + let trimmed = digits.drop { $0 == "0" } + return trimmed.isEmpty ? "0" : String(trimmed) + } + + private static func formatGroupedDigits(_ value: String, groupingSeparator: Character) -> String { + guard value.count > groupSize else { return value } + + var result = "" + let digits = Array(value) + + for index in digits.indices { + if index > 0, (digits.count - index).isMultiple(of: groupSize) { + result.append(groupingSeparator) + } + + result.append(digits[index]) + } + + return result + } + + private static func decimalValue(_ rawValue: String) -> Decimal { + Decimal(string: rawValue, locale: posixLocale) ?? .zero + } + + private static func roundedUInt64(_ value: Decimal) -> UInt64 { + let number = NSDecimalNumber(decimal: value) + let maxNumber = NSDecimalNumber(value: UInt64.max) + guard number.compare(maxNumber) != .orderedDescending else { return UInt64.max } + + let rounded = number.rounding(accordingToBehavior: NSDecimalNumberHandler( + roundingMode: .plain, + scale: 0, + raiseOnExactness: false, + raiseOnOverflow: false, + raiseOnUnderflow: false, + raiseOnDivideByZero: false + )) + return rounded.uint64Value + } + + private static func formatDecimal( + _ value: Decimal, + minimumFractionDigits: Int = 0, + maximumFractionDigits: Int + ) -> String { + let formatter = NumberFormatter() + formatter.locale = posixLocale + formatter.numberStyle = .decimal + formatter.usesGroupingSeparator = false + formatter.minimumFractionDigits = minimumFractionDigits + formatter.maximumFractionDigits = maximumFractionDigits + formatter.decimalSeparator = "." + return formatter.string(from: value as NSDecimalNumber) ?? "0" + } + + private static func trimTrailingZeros(_ value: String) -> String { + value.replacingOccurrences(of: #"\.?0+$"#, with: "", options: .regularExpression) + } +} + +private enum DecimalFormatSymbols { + static func decimalSeparator(locale: Locale) -> String { + let formatter = NumberFormatter() + formatter.locale = locale + return formatter.decimalSeparator ?? "." + } +} diff --git a/Bitkit/Models/Currency.swift b/Bitkit/Models/Currency.swift index 017d038be..0844bb22a 100644 --- a/Bitkit/Models/Currency.swift +++ b/Bitkit/Models/Currency.swift @@ -24,7 +24,7 @@ struct FxRate: Codable, Equatable { } } -enum BitcoinDisplayUnit: String, CaseIterable { +enum BitcoinDisplayUnit: String, CaseIterable, Codable { case modern case classic } diff --git a/Bitkit/Services/Widgets/CalculatorWidgetOptionsStore.swift b/Bitkit/Services/Widgets/CalculatorWidgetOptionsStore.swift new file mode 100644 index 000000000..591f2c132 --- /dev/null +++ b/Bitkit/Services/Widgets/CalculatorWidgetOptionsStore.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Stores the latest calculator values for Bitkit's in-app widget row and preview screen. +enum CalculatorWidgetOptionsStore { + private static let key = "calculator_widget_values_v1" + + static func save(_ values: CalculatorWidgetValues) { + guard let data = try? JSONEncoder().encode(values) + else { return } + UserDefaults.standard.set(data, forKey: key) + } + + static func load() -> CalculatorWidgetValues { + guard let data = UserDefaults.standard.data(forKey: key), + let values = try? JSONDecoder().decode(CalculatorWidgetValues.self, from: data) + else { + return CalculatorWidgetValues() + } + return values + } +} diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift index 22d3357d9..6fe927f38 100644 --- a/Bitkit/Views/Home/HomeWidgetsView.swift +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -1,6 +1,7 @@ import SwiftUI struct HomeWidgetsView: View { + @Environment(CalculatorInputManager.self) private var calculatorInput @EnvironmentObject var app: AppViewModel @Environment(KeyboardManager.self) private var keyboard @EnvironmentObject var navigation: NavigationViewModel @@ -13,6 +14,20 @@ struct HomeWidgetsView: View { @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + @State private var calculatorFrame: CGRect? + @State private var didApplyFirstCalculatorFocusPadding = false + @State private var didStartCalculatorDismissDrag = false + @StateObject private var focusAdjustmentState = CalculatorFocusAdjustmentState() + @State private var focusedContentOffsetY: CGFloat = 0 + @State private var firstCalculatorTopPadding: CGFloat = 0 + @State private var numberPadFrame: CGRect? + + private static let focusAnimation = Animation.easeOut(duration: focusAnimationDuration) + private static let focusAnimationDuration = 0.12 + private static let focusDismissDragMinimumDistance: CGFloat = 8 + private static let maxFocusAdjustmentPasses = 4 + private static let numberPadEstimatedHeight = 8 + NumberPad.contentHeight + (windowSafeAreaInsets.bottom > 0 ? windowSafeAreaInsets.bottom : 16) + private var isPaykitUIActive: Bool { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled } @@ -23,6 +38,10 @@ struct HomeWidgetsView: View { return keyboard.isPresented ? inset : ScreenLayout.bottomPaddingWithSafeArea } + private var isCalculatorFirst: Bool { + widgetsToShow.first?.type == .calculator + } + /// Widgets to display; suggestions widget is hidden when it would show no cards (unless editing). private var widgetsToShow: [Widget] { widgets.savedWidgets.filter { widget in @@ -38,46 +57,328 @@ struct HomeWidgetsView: View { } } + private var visibleWidgets: [Widget] { + widgetsToShow + } + var body: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - DraggableList( - widgetsToShow, - id: \.id, - enableDrag: isEditingWidgets, - itemHeight: 80, - onReorder: { sourceIndex, destinationIndex in - widgets.reorderWidgets(from: sourceIndex, to: destinationIndex) + ScrollViewReader { proxy in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + if isCalculatorFirst { + Color.clear + .frame(height: firstCalculatorTopPadding) } - ) { widget in - rowContent(widget) - } - .id(widgetsToShow.map(\.id)) - CustomButton(title: t("widgets__add"), variant: .tertiary) { - if app.hasSeenWidgetsIntro { - navigation.navigate(.widgetsList) - } else { - navigation.navigate(.widgetsIntro) + DraggableList( + visibleWidgets, + id: \.id, + enableDrag: isEditingWidgets && !calculatorInput.isPresented, + itemHeight: 80, + onReorder: { sourceIndex, destinationIndex in + widgets.reorderWidgets(from: sourceIndex, to: destinationIndex) + } + ) { widget in + rowContent(widget) } + .id(visibleWidgets.map(\.id)) + + CustomButton(title: t("widgets__add"), variant: .tertiary) { + calculatorInput.dismiss() + + if app.hasSeenWidgetsIntro { + navigation.navigate(.widgetsList) + } else { + navigation.navigate(.widgetsIntro) + } + } + .padding(.top, 16) + .opacity(calculatorInput.isPresented ? 0 : 1) + .allowsHitTesting(!calculatorInput.isPresented) + .accessibilityHidden(calculatorInput.isPresented) + .accessibilityIdentifier("WidgetsAdd") + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, ScreenLayout.topPaddingWithSafeArea) + .padding(.bottom, bottomPadding) + .padding(.horizontal) + .offset(y: focusedContentOffsetY) + .background { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + calculatorInput.dismiss() + } + } + } + .scrollDisabled(calculatorInput.isPresented) + .simultaneousGesture( + DragGesture(minimumDistance: Self.focusDismissDragMinimumDistance) + .onChanged(handleWidgetsPageDragChanged) + .onEnded { _ in + didStartCalculatorDismissDrag = false + }, + including: calculatorInput.isPresented || didStartCalculatorDismissDrag ? .all : .none + ) + // Dismiss (calculator widget) keyboard when scrolling + .scrollDismissesKeyboard(.interactively) + .onChange(of: calculatorInput.isPresented) { _, isPresented in + if isPresented { + startFocusedCalculatorTransition(proxy) + } else { + setFocusedContentOffsetY(0) + setFirstCalculatorTopPadding(0) + finishFocusedCalculatorDismissal() } - .padding(.top, 16) - .accessibilityIdentifier("WidgetsAdd") } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.top, ScreenLayout.topPaddingWithSafeArea) - .padding(.bottom, bottomPadding) - .padding(.horizontal) + .onPreferenceChange(CalculatorWidgetFramePreferenceKey.self) { frame in + calculatorFrame = frame + } + .onPreferenceChange(CalculatorNumberPadFramePreferenceKey.self) { frame in + numberPadFrame = frame + settleFocusedCalculator(proxy, numberPadFrame: frame) + } + .onDisappear { + calculatorInput.dismiss() + } + } + } + + private func startFocusedCalculatorTransition(_ proxy: ScrollViewProxy) { + if focusAdjustmentState.hasStartedPresentation { + if isCalculatorFirst, !didApplyFirstCalculatorFocusPadding { + applyFirstCalculatorFocusPadding(proxy) + } + + return + } + + resetFocusAdjustment() + focusAdjustmentState.hasStartedPresentation = true + + if isCalculatorFirst { + setFocusedContentOffsetY(0) + applyFirstCalculatorFocusPadding(proxy) + return + } + + if firstCalculatorTopPadding > 0 { + setFirstCalculatorTopPadding(0) + } + + settleFocusedCalculator(proxy) + } + + private func handleWidgetsPageDragChanged(_ value: DragGesture.Value) { + if calculatorInput.isPresented || didStartCalculatorDismissDrag { + didStartCalculatorDismissDrag = true + calculatorInput.dismiss() + } + } + + private func applyFirstCalculatorFocusPadding(_ proxy: ScrollViewProxy) { + guard let bottomGap = focusedNumberPadBottomGap(numberPadFrame: numberPadFrame) else { return } + + didApplyFirstCalculatorFocusPadding = true + setFirstCalculatorTopPadding(max(0, bottomGap)) + } + + private func settleFocusedCalculator( + _ proxy: ScrollViewProxy, + numberPadFrame proposedNumberPadFrame: CGRect? = nil + ) { + guard calculatorInput.isPresented else { return } + + let currentNumberPadFrame = proposedNumberPadFrame ?? numberPadFrame + + if isCalculatorFirst { + if !didApplyFirstCalculatorFocusPadding { + startFocusedCalculatorTransition(proxy) + return + } + + settleFirstCalculatorStack(numberPadFrame: currentNumberPadFrame) + return + } else if firstCalculatorTopPadding > 0 { + setFirstCalculatorTopPadding(0) + } + + guard let bottomGap = focusedNumberPadBottomGap(numberPadFrame: currentNumberPadFrame), + !focusAdjustmentState.isAdjusting + else { return } + + guard abs(bottomGap) > 1 else { + focusAdjustmentState.resetCorrection() + return } - // Dismiss (calculator widget) keyboard when scrolling - .scrollDismissesKeyboard(.interactively) + + guard focusAdjustmentState.passes < Self.maxFocusAdjustmentPasses else { return } + + adjustFocusedCalculatorByBottomGap(bottomGap, numberPadFrame: currentNumberPadFrame) } + private func settleFirstCalculatorStack(numberPadFrame: CGRect?) { + guard let bottomGap = focusedNumberPadBottomGap(numberPadFrame: numberPadFrame) else { return } + guard abs(bottomGap) > 1 else { return } + guard shouldApplyFocusAdjustment(for: numberPadFrame) else { return } + + setFirstCalculatorTopPadding(max(0, firstCalculatorTopPadding + bottomGap)) + } + + private func focusedNumberPadBottomGap(numberPadFrame: CGRect?) -> CGFloat? { + guard let numberPadFrame else { return nil } + + if numberPadFrame.height >= Self.numberPadEstimatedHeight - 1 { + return focusBottomY - numberPadFrame.maxY + } + + if numberPadFrame.height >= NumberPad.contentHeight - 1 { + return numberPadButtonsBottomY - numberPadFrame.maxY + } + + return nil + } + + private func adjustFocusedCalculatorByBottomGap(_ bottomGap: CGFloat, numberPadFrame: CGRect?) { + guard shouldApplyFocusAdjustment(for: numberPadFrame) else { return } + + focusAdjustmentState.delta = (focusAdjustmentState.delta ?? 0) + bottomGap + focusAdjustmentState.passes += 1 + focusAdjustmentState.isAdjusting = true + setFocusedContentOffsetY(focusedContentOffsetY + bottomGap) + + DispatchQueue.main.asyncAfter(deadline: .now() + Self.focusAnimationDuration + 0.03) { + guard calculatorInput.isPresented, !isCalculatorFirst else { return } + focusAdjustmentState.isAdjusting = false + } + } + + private func shouldApplyFocusAdjustment(for numberPadFrame: CGRect?) -> Bool { + guard let numberPadFrame else { return true } + + let maxY = numberPadFrame.maxY + if let lastAdjustedNumberPadMaxY = focusAdjustmentState.lastAdjustedNumberPadMaxY, abs(lastAdjustedNumberPadMaxY - maxY) <= 1 { + return false + } + + focusAdjustmentState.lastAdjustedNumberPadMaxY = maxY + return true + } + + private func resetFocusAdjustment() { + focusAdjustmentState.reset() + } + + private func finishFocusedCalculatorDismissal() { + DispatchQueue.main.asyncAfter(deadline: .now() + Self.focusAnimationDuration + 0.05) { + guard !calculatorInput.isPresented else { return } + + calculatorFrame = nil + didApplyFirstCalculatorFocusPadding = false + numberPadFrame = nil + resetFocusAdjustment() + } + } + + private func setFirstCalculatorTopPadding(_ value: CGFloat) { + guard abs(value - firstCalculatorTopPadding) > 1 else { return } + + withAnimation(Self.focusAnimation) { + firstCalculatorTopPadding = value + } + } + + private func setFocusedContentOffsetY(_ value: CGFloat) { + guard abs(value - focusedContentOffsetY) > 1 else { return } + + withAnimation(Self.focusAnimation) { + focusedContentOffsetY = value + } + } + + private var focusBottomY: CGFloat { + UIScreen.main.bounds.height + } + + private var numberPadButtonsBottomY: CGFloat { + focusBottomY - (windowSafeAreaInsets.bottom > 0 ? windowSafeAreaInsets.bottom : 16) + } + + @ViewBuilder private func rowContent(_ widget: Widget) -> some View { - widget.view( - widgetsViewModel: widgets, - isEditing: isEditingWidgets, - onEditingEnd: { withAnimation { isEditingWidgets = false } } - ) + if widget.type == .calculator { + widget.view( + widgetsViewModel: widgets, + isEditing: isEditingWidgets, + onEditingEnd: { withAnimation { isEditingWidgets = false } } + ) + .id(widget.id) + .trackCalculatorWidgetFrame() + } else { + let content = widget.view( + widgetsViewModel: widgets, + isEditing: isEditingWidgets, + onEditingEnd: { withAnimation { isEditingWidgets = false } } + ) + .id(widget.id) + + if calculatorInput.isPresented { + ZStack { + content + .allowsHitTesting(false) + + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + calculatorInput.dismiss() + } + } + } else { + content + } + } + } +} + +private final class CalculatorFocusAdjustmentState: ObservableObject { + var delta: CGFloat? + var hasStartedPresentation = false + var isAdjusting = false + var lastAdjustedNumberPadMaxY: CGFloat? + var passes = 0 + + func reset() { + delta = nil + hasStartedPresentation = false + isAdjusting = false + lastAdjustedNumberPadMaxY = nil + passes = 0 + } + + func resetCorrection() { + delta = nil + lastAdjustedNumberPadMaxY = nil + passes = 0 + } +} + +private struct CalculatorWidgetFramePreferenceKey: PreferenceKey { + static var defaultValue: CGRect? + + static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) { + value = nextValue() ?? value + } +} + +private extension View { + func trackCalculatorWidgetFrame() -> some View { + background { + GeometryReader { proxy in + Color.clear.preference( + key: CalculatorWidgetFramePreferenceKey.self, + value: proxy.frame(in: .global) + ) + } + } } } diff --git a/Bitkit/Views/HomeScreen.swift b/Bitkit/Views/HomeScreen.swift index 6b382d06c..b92cf00fc 100644 --- a/Bitkit/Views/HomeScreen.swift +++ b/Bitkit/Views/HomeScreen.swift @@ -1,6 +1,7 @@ import SwiftUI struct HomeScreen: View { + @Environment(CalculatorInputManager.self) private var calculatorInput @EnvironmentObject var activity: ActivityListViewModel @EnvironmentObject var app: AppViewModel @EnvironmentObject var settings: SettingsViewModel @@ -39,6 +40,10 @@ struct HomeScreen: View { .scrollTargetBehavior(.paging) .scrollPosition(id: $scrollPosition) .onChange(of: scrollPosition) { _, newValue in + if newValue != 1 { + calculatorInput.dismiss() + } + // Dismiss this hint after the user has seen it and scrolls to widgets if hasActivity, newValue == 1 { app.hasDismissedWidgetsOnboardingHint = true diff --git a/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift b/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift new file mode 100644 index 000000000..4505057c5 --- /dev/null +++ b/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift @@ -0,0 +1,226 @@ +import SwiftUI + +/// Preview screen for the Calculator widget. +struct CalculatorWidgetPreviewView: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + @EnvironmentObject private var currency: CurrencyViewModel + + // TODO: revert to 0 to re-enable the compact widget preview + @State private var carouselPage: Int = 1 + @State private var previewActiveInput: CalculatorMoneyType? + @State private var showDeleteAlert = false + @State private var values = CalculatorWidgetValues() + + private let widgetType: WidgetType = .calculator + + private var widgetName: String { + t("widgets__calculator__name") + } + + private var widgetDescription: String { + t("widgets__calculator__description", variables: ["fiatSymbol": currency.symbol]) + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(widgetType) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + NavigationBar(title: widgetName, showMenuButton: false) + + BodyMText(widgetDescription, textColor: .textSecondary) + + VStack(spacing: 16) { + carousel + + // Size label hidden while only the wide widget is shown + // sizeLabel + + // Page indicator hidden while only the wide widget is shown + // pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + buttonsRow + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .task { + hydrateValues() + } + .onChange(of: currency.selectedCurrency) { + hydrateValues() + } + .onChange(of: currency.displayUnit) { + hydrateValues() + } + .onChange(of: currency.rates) { + hydrateValues() + } + .onDisappear { + previewActiveInput = nil + } + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteAlert, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } + Button(t("common__delete_yes"), role: .destructive) { onDelete() } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": widgetName])) + } + ) + } + + private var carousel: some View { + TabView(selection: $carouselPage) { + // Compact preview temporarily hidden — only the wide widget can be added for now + // compactPage.tag(0) + widePage.tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(maxHeight: .infinity) + } + + private var compactPage: some View { + VStack { + Spacer(minLength: 0) + CalculatorWidgetCompactContent(values: values) + .frame(width: 163, height: 192) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private var widePage: some View { + VStack { + Spacer(minLength: 0) + CalculatorWidgetWideContent( + values: values, + activeInput: previewActiveInput, + onSelectInput: selectInput + ) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + .frame(maxWidth: .infinity) + Spacer(minLength: 0) + } + } + + private var sizeLabel: some View { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__widget__size_small") + : t("widgets__widget__size_wide"), + textColor: .textSecondary + ) + .textCase(.uppercase) + Spacer() + } + } + + private var pageIndicator: some View { + HStack(spacing: 8) { + Spacer() + ForEach(0 ..< 2, id: \.self) { index in + Circle() + .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) + .frame(width: 8, height: 8) + } + Spacer() + } + } + + private var buttonsRow: some View { + HStack(spacing: 16) { + if isWidgetSaved { + CustomButton( + title: t("common__delete"), + variant: .secondary, + size: .large, + shouldExpand: true + ) { + showDeleteAlert = true + } + .accessibilityIdentifier("WidgetDelete") + } + + CustomButton( + title: t("widgets__widget__save_widget"), + variant: .primary, + size: .large, + shouldExpand: true, + action: onSave + ) + .accessibilityIdentifier("WidgetSave") + } + } + + private func hydrateValues() { + let saved = CalculatorWidgetOptionsStore.load() + let bitcoinValue = Self.previewBitcoinValue(saved: saved, displayUnit: currency.displayUnit) + + values = CalculatorWidgetValues( + bitcoinValue: bitcoinValue, + fiatValue: Self.previewFiatValue(saved: saved, recalculatedFiatValue: fiatValue(for: bitcoinValue)), + displayUnit: currency.displayUnit, + currencySymbol: currency.symbol, + selectedCurrency: currency.selectedCurrency + ) + } + + static func previewBitcoinValue(saved: CalculatorWidgetValues, displayUnit: BitcoinDisplayUnit) -> String { + guard !saved.bitcoinValue.isEmpty else { return "" } + + let savedSats = CalculatorWidgetFormatter.bitcoinValueToSats(saved.bitcoinValue, displayUnit: saved.displayUnit) + return savedSats == 0 + ? "0" + : CalculatorWidgetFormatter.satsToBitcoinValue(savedSats, displayUnit: displayUnit) + } + + static func previewFiatValue(saved: CalculatorWidgetValues, recalculatedFiatValue: String) -> String { + saved.shouldRefreshBitcoinFromFiat ? saved.fiatValue : recalculatedFiatValue + } + + private func fiatValue(for bitcoinValue: String) -> String { + guard !bitcoinValue.isEmpty else { return "" } + let sats = CalculatorWidgetFormatter.bitcoinValueToSats(bitcoinValue, displayUnit: currency.displayUnit) + if sats == 0 { return "0.00" } + guard let converted = currency.convert(sats: sats) else { + return "" + } + return CalculatorWidgetFormatter.fiatRawValue(from: converted.value) + } + + private func selectInput(_ input: CalculatorMoneyType) { + previewActiveInput = input + } + + private func onSave() { + widgets.saveWidget(widgetType) + navigation.reset() + } + + private func onDelete() { + widgets.deleteWidget(widgetType) + navigation.reset() + } +} + +#Preview { + NavigationStack { + CalculatorWidgetPreviewView() + .environmentObject(NavigationViewModel()) + .environmentObject(WidgetsViewModel()) + .environmentObject(CurrencyViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/BitkitTests/CalculatorWidgetTests.swift b/BitkitTests/CalculatorWidgetTests.swift new file mode 100644 index 000000000..735194ad5 --- /dev/null +++ b/BitkitTests/CalculatorWidgetTests.swift @@ -0,0 +1,185 @@ +@testable import Bitkit +import XCTest + +final class CalculatorWidgetTests: XCTestCase { + func testModernBitcoinFormattingUsesSpaceGrouping() { + XCTAssertEqual( + CalculatorWidgetFormatter.formatBitcoinValue("1800000000", displayUnit: .modern), + "1 800 000 000" + ) + } + + func testClassicBitcoinFormattingUsesEightDecimalPlaceholder() { + XCTAssertEqual(CalculatorWidgetFormatter.formatBitcoinPlaceholder("", displayUnit: .classic), ".00000000") + XCTAssertEqual(CalculatorWidgetFormatter.formatBitcoinPlaceholder("1", displayUnit: .classic), ".00000000") + XCTAssertEqual(CalculatorWidgetFormatter.formatBitcoinPlaceholder("1.", displayUnit: .classic), "00000000") + XCTAssertEqual(CalculatorWidgetFormatter.formatBitcoinPlaceholder("1.2", displayUnit: .classic), "0000000") + XCTAssertEqual(CalculatorWidgetFormatter.formatBitcoinPlaceholder("1.23456789", displayUnit: .classic), "") + XCTAssertEqual(CalculatorWidgetFormatter.formatBitcoinPlaceholder("1000", displayUnit: .modern), "") + } + + func testFiatFormattingUsesCommaGroupingAndPlaceholderZero() { + XCTAssertEqual(CalculatorWidgetFormatter.formatFiatValue("82209.8"), "82,209.8") + XCTAssertEqual(CalculatorWidgetFormatter.formatFiatPlaceholder("82209.8"), "0") + } + + func testCalculatorNumberPadDecimalSeparatorAlwaysUsesPeriod() { + XCTAssertEqual(CalculatorWidgetFormatter.numberPadDecimalSeparator(), ".") + } + + func testNumberPadDeleteOperatesOnRawValue() { + let next = CalculatorWidgetFormatter.applyNumberPadInput( + rawValue: "1000", + key: "delete", + maxDecimalPlaces: CalculatorWidgetFormatter.fiatDecimalPlaces + ) + + XCTAssertEqual(next, "100") + XCTAssertEqual(CalculatorWidgetFormatter.formatFiatValue(next), "100") + } + + func testNumberPadClearRemovesRawValue() { + let next = CalculatorWidgetFormatter.applyNumberPadInput( + rawValue: "1000.50", + key: "clear", + maxDecimalPlaces: CalculatorWidgetFormatter.fiatDecimalPlaces + ) + + XCTAssertEqual(next, "") + } + + func testNumberPadCapsFiatDecimals() { + let value = CalculatorWidgetFormatter.applyNumberPadInput( + rawValue: "1.50", + key: "0", + maxDecimalPlaces: CalculatorWidgetFormatter.fiatDecimalPlaces + ) + + XCTAssertEqual(value, "1.50") + } + + func testLocalizedCommaDecimalInputNormalizesToCalculatorDecimal() { + let locale = Locale(identifier: "fr_BE") + let value = CalculatorWidgetFormatter.applyNumberPadInput( + rawValue: "1,", + key: "5", + maxDecimalPlaces: CalculatorWidgetFormatter.fiatDecimalPlaces, + locale: locale + ) + + XCTAssertEqual(value, "1.5") + } + + func testLocalizedCommaDecimalKeyAppendsCalculatorDecimal() { + let locale = Locale(identifier: "fr_BE") + let value = CalculatorWidgetFormatter.applyNumberPadInput( + rawValue: "1", + key: ",", + maxDecimalPlaces: CalculatorWidgetFormatter.fiatDecimalPlaces, + locale: locale + ) + + XCTAssertEqual(value, "1.") + } + + func testPeriodDecimalKeyWorksForLocalizedFiatInput() { + let locale = Locale(identifier: "fr_BE") + let value = CalculatorWidgetFormatter.applyNumberPadInput( + rawValue: "1", + key: ".", + maxDecimalPlaces: CalculatorWidgetFormatter.fiatDecimalPlaces, + locale: locale + ) + + XCTAssertEqual(value, "1.") + } + + func testPersistedFiatOnlyValuesUseFiatAsSource() { + let values = CalculatorWidgetValues(bitcoinValue: "", fiatValue: "12.34") + + XCTAssertTrue(values.shouldRefreshBitcoinFromFiat) + } + + func testFiatActiveInputStaysRefreshSourceWhenBothValuesExist() { + let values = CalculatorWidgetValues(bitcoinValue: "10000", fiatValue: "12.34") + + XCTAssertEqual(values.refreshSource(activeInput: .fiat), .fiat) + } + + func testBitcoinActiveInputStaysRefreshSourceWhenFiatOnlyWouldOtherwiseWin() { + let values = CalculatorWidgetValues(bitcoinValue: "", fiatValue: "12.34") + + XCTAssertEqual(values.refreshSource(activeInput: .bitcoin), .bitcoin) + } + + func testEmptyFiatActiveInputSkipsRefreshSource() { + let values = CalculatorWidgetValues(bitcoinValue: "10000", fiatValue: "") + + XCTAssertNil(values.refreshSource(activeInput: .fiat)) + } + + func testRefreshSourceFallsBackToFiatOnlyValue() { + let values = CalculatorWidgetValues(bitcoinValue: "", fiatValue: "12.34") + + XCTAssertEqual(values.refreshSource(activeInput: nil), .fiat) + } + + func testRefreshSourceFallsBackToBitcoinWhenBothValuesExist() { + let values = CalculatorWidgetValues(bitcoinValue: "10000", fiatValue: "12.34") + + XCTAssertEqual(values.refreshSource(activeInput: nil), .bitcoin) + } + + func testPreviewPreservesPersistedFiatOnlyValue() { + let values = CalculatorWidgetValues(bitcoinValue: "", fiatValue: "12.34") + + XCTAssertEqual(CalculatorWidgetPreviewView.previewFiatValue(saved: values, recalculatedFiatValue: ""), "12.34") + } + + func testPreviewUsesRecalculatedFiatWhenBitcoinValueExists() { + let values = CalculatorWidgetValues(bitcoinValue: "10000", fiatValue: "12.34") + + XCTAssertEqual(CalculatorWidgetPreviewView.previewFiatValue(saved: values, recalculatedFiatValue: "10.00"), "10.00") + } + + func testPreviewKeepsPersistedZeroBitcoinValueVisible() { + let values = CalculatorWidgetValues(bitcoinValue: "0", fiatValue: "0.00") + + XCTAssertEqual(CalculatorWidgetPreviewView.previewBitcoinValue(saved: values, displayUnit: .modern), "0") + XCTAssertEqual(CalculatorWidgetPreviewView.previewBitcoinValue(saved: values, displayUnit: .classic), "0") + } + + func testCurrencySymbolFallsBackToFirstCharacterForLongSymbols() { + XCTAssertEqual(CalculatorWidgetFormatter.displaySymbol("CHF"), "C") + XCTAssertEqual(CalculatorWidgetFormatter.displaySymbol("$"), "$") + } + + func testClassicBitcoinConvertsToSats() { + XCTAssertEqual( + CalculatorWidgetFormatter.bitcoinValueToSats("0.00010000", displayUnit: .classic), + 10000 + ) + } + + func testFiatConversionKeepsZeroSatsVisible() { + XCTAssertEqual(CalculatorWidgetFormatter.fiatConversionBitcoinValue(0, displayUnit: .modern), "0") + XCTAssertEqual(CalculatorWidgetFormatter.fiatConversionBitcoinValue(0, displayUnit: .classic), "0") + } + + func testFiatConversionReturnsNilWhenRateUnavailable() { + let sats = CalculatorWidgetFormatter.convertedSatsFromFiat("12.34") { _ in nil } + + XCTAssertNil(sats) + } + + func testZeroFiatConversionDoesNotRequireRate() { + let sats = CalculatorWidgetFormatter.convertedSatsFromFiat("0") { _ in nil } + + XCTAssertEqual(sats, 0) + } + + func testClassicBitcoinRejectsValuesAboveSupply() { + XCTAssertTrue(CalculatorWidgetFormatter.exceedsMaxBitcoin("21000000.00000001", displayUnit: .classic)) + XCTAssertFalse(CalculatorWidgetFormatter.exceedsMaxBitcoin("21000000", displayUnit: .classic)) + } +} diff --git a/changelog.d/next/554.changed.md b/changelog.d/next/554.changed.md new file mode 100644 index 000000000..d8aef0565 --- /dev/null +++ b/changelog.d/next/554.changed.md @@ -0,0 +1 @@ +Redesigned the Bitcoin Calculator widget to v61 design and replaced the OS keyboard with a dark-themed in-app numpad From af4e8bfc5d15aaa939d4ba67108fb957e43f429b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 25 May 2026 22:18:44 +0200 Subject: [PATCH 2/2] fix: expose nested widget accessibility identifiers --- Bitkit/Components/Widgets/BaseWidget.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Bitkit/Components/Widgets/BaseWidget.swift b/Bitkit/Components/Widgets/BaseWidget.swift index 09446fd3f..4d543743b 100644 --- a/Bitkit/Components/Widgets/BaseWidget.swift +++ b/Bitkit/Components/Widgets/BaseWidget.swift @@ -191,6 +191,7 @@ struct BaseWidget: View { } } .contentShape(Rectangle()) + .accessibilityElement(children: .contain) .accessibilityIdentifierIfPresent(isEditing ? nil : "\(type.rawValue.capitalized)Widget") .frame(maxWidth: .infinity) .padding((hasBackground || isEditing) ? 16 : 0)