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..b01b11025 100644 --- a/Bitkit/Components/Widgets/BaseWidget.swift +++ b/Bitkit/Components/Widgets/BaseWidget.swift @@ -124,98 +124,98 @@ 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) + widgetContent + .accessibilityIdentifierIfPresent(isEditing ? nil : "\(type.rawValue.capitalized)Widget") + .frame(maxWidth: .infinity) + .padding((hasBackground || isEditing) ? 16 : 0) + .background((hasBackground || isEditing) ? Color.gray6 : Color.clear) + .cornerRadius(hasBackground || isEditing ? 16 : 0) + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteDialog, + actions: { + Button(t("common__cancel"), role: .cancel) { + showDeleteDialog = false + } - BodyMSBText(truncate(metadata.name, 18)) - .lineLimit(1) - } + Button(t("common__delete_yes"), role: .destructive) { + widgets.deleteWidget(type) + showDeleteDialog = false + } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": metadata.name])) + } + ) + } - 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") + private var widgetContent: some View { + 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) + } + + Spacer() - 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 - } } - .contentShape(Rectangle()) - } - .accessibilityIdentifier("\(type.rawValue.capitalized)Widget") - .buttonStyle(WidgetButtonStyle()) - .frame(maxWidth: .infinity) - .padding((hasBackground || isEditing) ? 16 : 0) - .background((hasBackground || isEditing) ? Color.gray6 : Color.clear) - .cornerRadius(hasBackground || isEditing ? 16 : 0) - .alert( - t("widgets__delete__title"), - isPresented: $showDeleteDialog, - actions: { - Button(t("common__cancel"), role: .cancel) { - showDeleteDialog = false - } - Button(t("common__delete_yes"), role: .destructive) { - widgets.deleteWidget(type) - showDeleteDialog = false - } - }, - message: { - Text(t("widgets__delete__description", variables: ["name": metadata.name])) + // Widget content (only shown when not editing) + if !isEditing { + content } - ) + } } /// Truncate a string to a maximum length @@ -229,14 +229,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 { @@ -269,6 +261,5 @@ struct WidgetButtonStyle: ButtonStyle { .environmentObject(WidgetsViewModel()) .environmentObject(NavigationViewModel()) .environmentObject(CurrencyViewModel()) - .environmentObject(SettingsViewModel.shared) .preferredColorScheme(.dark) } diff --git a/Bitkit/Components/Widgets/CalculatorWidget.swift b/Bitkit/Components/Widgets/CalculatorWidget.swift index 1e50d0719..970380b44 100644 --- a/Bitkit/Components/Widgets/CalculatorWidget.swift +++ b/Bitkit/Components/Widgets/CalculatorWidget.swift @@ -1,65 +1,17 @@ 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" + @State private var values = CalculatorWidgetValues() + @State private var hasHydrated = false + @State private var previousDisplayUnit: BitcoinDisplayUnit = .modern - /// 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 - } - - /// Initialize the widget init( isEditing: Bool = false, onEditingEnd: (() -> Void)? = nil @@ -69,271 +21,458 @@ 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 calculatorInput.isPresented { + numberPad + .trackCalculatorNumberPadFrame() + .transition(.identity) } } - .onAppear { - // Initialize fiat amount on first load - if fiatAmount.isEmpty { - updateFiatAmount(from: bitcoinAmount) - } + .animation(.easeOut(duration: 0.14), value: calculatorInput.isPresented) + .task { + hydrateValuesIfNeeded() } .onChange(of: currency.selectedCurrency) { - // Update fiat amount when currency changes - updateFiatAmount(from: bitcoinAmount) + refreshCurrencyFields() + refreshDerivedValue() + refreshNumberPadConfiguration() + persistValues() + } + .onChange(of: currency.displayUnit) { _, newUnit in + convertBitcoinValue(to: newUnit) + refreshCurrencyFields() + refreshDerivedValue() + refreshNumberPadConfiguration() + persistValues() + } + .onChange(of: currency.rates) { + refreshCurrencyFields() + refreshDerivedValue() + persistValues() + } + .onChange(of: calculatorInput.submittedKey?.id) { + guard let key = calculatorInput.submittedKey?.value else { return } + handleNumberPadInput(key) } } - /// Updates fiat amount based on bitcoin input - private func updateFiatAmount(from bitcoin: String) { - // Sanitize bitcoin input - let sanitizedBitcoin = sanitizeBitcoinInput(bitcoin) - - guard let amount = UInt64(sanitizedBitcoin), amount > 0 else { - fiatAmount = "" - return + 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)) } + } - // Cap the amount at maximum bitcoin - let cappedAmount = min(amount, MAX_BITCOIN) + private var currentValues: CalculatorWidgetValues { + CalculatorWidgetValues( + bitcoinValue: values.bitcoinValue, + fiatValue: values.fiatValue, + displayUnit: currency.displayUnit, + currencySymbol: currency.symbol, + selectedCurrency: currency.selectedCurrency + ) + } - // Convert to fiat - if let converted = currency.convert(sats: cappedAmount) { - fiatAmount = formatFiatAmount(converted.value) - } else { - fiatAmount = "" - } + private func hydrateValuesIfNeeded() { + guard !hasHydrated else { return } + hasHydrated = true + + let saved = CalculatorHomeScreenWidgetOptionsStore.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() + } - // Update bitcoin amount if it was capped or needs formatting - let formattedBitcoin = formatNumberWithSeparators(String(cappedAmount)) - if formattedBitcoin != bitcoin { - bitcoinAmount = formattedBitcoin - } + private func selectInput(_ input: CalculatorMoneyType) { + calculatorInput.activate( + input, + numberPadType: numberPadType(for: input), + decimalSeparator: CalculatorWidgetFormatter.decimalSeparator() + ) } - /// Updates bitcoin amount based on fiat input - private func updateBitcoinAmount(from fiat: String) { - // Sanitize fiat input - let sanitizedFiat = sanitizeFiatInput(fiat) + private func handleNumberPadInput(_ key: String) { + guard let activeInput = calculatorInput.activeInput else { return } + + let currentValue = rawValue(for: activeInput) + let nextValue = CalculatorWidgetFormatter.applyNumberPadInput( + rawValue: currentValue, + key: key, + maxDecimalPlaces: maxDecimalPlaces(for: activeInput) + ) - guard let amount = Double(sanitizedFiat), amount > 0 else { - bitcoinAmount = "" + 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() } + + persistValues() } - /// 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) + private func rawValue(for input: CalculatorMoneyType) -> String { + switch input { + case .bitcoin: + return values.bitcoinValue + case .fiat: + return values.fiatValue + } } - /// 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: "") - - 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 } + } + + private func refreshNumberPadConfiguration() { + guard let activeInput = calculatorInput.activeInput else { return } + calculatorInput.updateConfiguration( + numberPadType: numberPadType(for: activeInput), + decimalSeparator: CalculatorWidgetFormatter.decimalSeparator() + ) + } - return processed.replacingOccurrences(of: "^0+(?=\\d)", with: "", options: .regularExpression) + private func refreshCurrencyFields() { + values.displayUnit = currency.displayUnit + values.currencySymbol = currency.symbol + values.selectedCurrency = currency.selectedCurrency } - /// 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 convertBitcoinValue(to newUnit: BitcoinDisplayUnit) { + guard previousDisplayUnit != newUnit else { return } - let integer = components[0] - let formattedInteger = integer.replacingOccurrences(of: "\\B(?=(\\d{3})+(?!\\d))", with: " ", options: .regularExpression) + let sats = CalculatorWidgetFormatter.bitcoinValueToSats(values.bitcoinValue, displayUnit: previousDisplayUnit) + values.bitcoinValue = CalculatorWidgetFormatter.satsToBitcoinValue(sats, displayUnit: newUnit) + previousDisplayUnit = newUnit + } - if components.count > 1 { - return formattedInteger + "." + components[1] + private func refreshDerivedValue() { + if values.shouldRefreshBitcoinFromFiat { + refreshBitcoinFromFiat() + } else { + refreshFiatFromBitcoin() } - - return endsWithDecimal ? formattedInteger + "." : formattedInteger } - /// 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() { + guard !values.fiatValue.isEmpty else { + values.bitcoinValue = "" + return + } + + let fiatValue = CalculatorWidgetFormatter.fiatDecimalValue(values.fiatValue) + if NSDecimalNumber(decimal: fiatValue).compare(NSDecimalNumber.zero) == .orderedSame { + values.bitcoinValue = CalculatorWidgetFormatter.fiatConversionBitcoinValue(0, displayUnit: currency.displayUnit) + 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] + let fiatDouble = NSDecimalNumber(decimal: fiatValue).doubleValue + if let sats = currency.convert(fiatAmount: fiatDouble) { + 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 = "" + } } + } else { + values.bitcoinValue = "" } + } - return input + private func persistValues() { + guard hasHydrated else { return } + CalculatorHomeScreenWidgetOptionsStore.save(currentValues) } - /// 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 showInputError(for key: String) { + Haptics.notify(.warning) + calculatorInput.errorKey = key + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if calculatorInput.errorKey == key { + calculatorInput.errorKey = nil + } + } + } +} - // Check if input matches valid pattern: digits, optional dot, up to 2 decimal digits - let validPattern = "^\\d*\\.?\\d{0,2}$" +// MARK: - Wide layout (in-app + carousel page) - // Allow empty string, single dot, or "0." - if processed.isEmpty || processed == "." || processed == "0." { - return processed - } +struct CalculatorWidgetWideContent: View { + let values: CalculatorWidgetValues + var activeInput: CalculatorMoneyType? + var onSelectInput: ((CalculatorMoneyType) -> Void)? + + var body: some View { + VStack(spacing: 16) { + CalculatorWidgetRow( + currencySymbol: "₿", + value: CalculatorWidgetFormatter.formatBitcoinValue(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 } + } +} + +// 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) + } +} - // If invalid, return the previous valid value by removing the last character - return String(processed.dropLast()) +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)? + + var body: some View { + if let onTap { + Button(action: onTap) { + rowContent + } + .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityIdentifier ?? "") + } else { + rowContent + } } - /// Validates bitcoin input to ensure only numbers and spaces - private func validateBitcoinInput(_ input: String) -> String { - // Allow empty input - if input.isEmpty { - return input + 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 +480,7 @@ struct CalculatorWidget: View { CalculatorWidget() .padding() .background(Color.black) + .environment(CalculatorInputManager()) .environmentObject(CurrencyViewModel()) .preferredColorScheme(.dark) } @@ -349,6 +489,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..99886d81e --- /dev/null +++ b/Bitkit/Models/CalculatorWidgetData.swift @@ -0,0 +1,371 @@ +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 + } +} + +enum CalculatorMoneyType { + 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 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 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 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/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index d73eb3fa3..dfee32ab0 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1387,6 +1387,9 @@ "widgets__list__button" = "Enable In Settings"; "widgets__delete__title" = "Delete Widget?"; "widgets__delete__description" = "Are you sure you want to delete '{name}' from your widgets?"; +"widgets__widget__size_small" = "Small"; +"widgets__widget__size_wide" = "Wide"; +"widgets__widget__save_widget" = "Save Widget"; "widgets__price__name" = "Bitcoin Price"; "widgets__price__description" = "Check the latest Bitcoin exchange rates for a variety of fiat currencies."; "widgets__price__error" = "Couldn\'t get price data"; diff --git a/Bitkit/Services/Widgets/CalculatorHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/CalculatorHomeScreenWidgetOptionsStore.swift new file mode 100644 index 000000000..3b38c2f27 --- /dev/null +++ b/Bitkit/Services/Widgets/CalculatorHomeScreenWidgetOptionsStore.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Stores the latest calculator values in the App Group so the in-app widget preview and home row share state. +enum CalculatorHomeScreenWidgetOptionsStore { + private static let suiteName = "group.bitkit" + private static let key = "home_screen_calculator_widget_values_v1" + + static func save(_ values: CalculatorWidgetValues) { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = try? JSONEncoder().encode(values) + else { return } + defaults.set(data, forKey: key) + } + + static func load() -> CalculatorWidgetValues { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = defaults.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..4e0d21a88 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,16 +14,34 @@ struct HomeWidgetsView: View { @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + @State private var calculatorFrame: CGRect? + @State private var didApplyFirstCalculatorFocusPadding = false + @State private var firstCalculatorTopPadding: CGFloat = 0 + @State private var focusScrollState = CalculatorFocusScrollState() + @State private var numberPadFrame: CGRect? + @State private var scrollView: UIScrollView? + + private static let focusAnimation = Animation.easeOut(duration: focusAnimationDuration) + private static let focusAnimationDuration = 0.12 + private static let maxFocusScrollRetries = 3 + private static let numberPadEstimatedHeight = 8 + NumberPad.contentHeight + (windowSafeAreaInsets.bottom > 0 ? windowSafeAreaInsets.bottom : 16) + private var isPaykitUIActive: Bool { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled } private var bottomPadding: CGFloat { + if calculatorInput.isPresented { return 0 } + // Keep the calculator widget fully scrollable above the keyboard. let inset = keyboard.height + ScreenLayout.bottomSpacing 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,342 @@ struct HomeWidgetsView: View { } } + private var visibleWidgets: [Widget] { + guard calculatorInput.isPresented, + let calculatorIndex = widgetsToShow.firstIndex(where: { $0.type == .calculator }) + else { + return widgetsToShow + } + + return Array(widgetsToShow.prefix(through: calculatorIndex)) + } + 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) + } + + 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)) + + if !calculatorInput.isPresented { + CustomButton(title: t("widgets__add"), variant: .tertiary) { + calculatorInput.dismiss() + + if app.hasSeenWidgetsIntro { + navigation.navigate(.widgetsList) + } else { + navigation.navigate(.widgetsIntro) + } + } + .padding(.top, 16) + .accessibilityIdentifier("WidgetsAdd") } - ) { widget in - rowContent(widget) } - .id(widgetsToShow.map(\.id)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, ScreenLayout.topPaddingWithSafeArea) + .padding(.bottom, bottomPadding) + .padding(.horizontal) + .background { + ZStack { + ScrollViewResolver { resolvedScrollView in + scrollView = resolvedScrollView + } - CustomButton(title: t("widgets__add"), variant: .tertiary) { - if app.hasSeenWidgetsIntro { - navigation.navigate(.widgetsList) - } else { - navigation.navigate(.widgetsIntro) + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + calculatorInput.dismiss() + } + } + } + } + .scrollDisabled(calculatorInput.isPresented) + .simultaneousGesture( + DragGesture(minimumDistance: 8).onChanged { _ in + if calculatorInput.isPresented { + calculatorInput.dismiss() } } - .padding(.top, 16) - .accessibilityIdentifier("WidgetsAdd") + ) + // Dismiss (calculator widget) keyboard when scrolling + .scrollDismissesKeyboard(.interactively) + .onChange(of: calculatorInput.isPresented) { _, isPresented in + if isPresented { + startFocusedCalculatorTransition(proxy) + } else { + calculatorFrame = nil + didApplyFirstCalculatorFocusPadding = false + numberPadFrame = nil + focusScrollState.reset() + setFirstCalculatorTopPadding(0) + } + } + .onPreferenceChange(CalculatorWidgetFramePreferenceKey.self) { frame in + calculatorFrame = frame + settleFocusedCalculator(proxy) + } + .onPreferenceChange(CalculatorNumberPadFramePreferenceKey.self) { frame in + numberPadFrame = frame + settleFocusedCalculator(proxy, numberPadFrame: frame) + } + .onDisappear { + calculatorInput.dismiss() + } + } + } + + private func startFocusedCalculatorTransition(_ proxy: ScrollViewProxy) { + focusScrollState.reset(targetY: scrollView?.contentOffset.y) + + if isCalculatorFirst { + applyFirstCalculatorFocusPadding(proxy) + return + } + + if firstCalculatorTopPadding > 0 { + setFirstCalculatorTopPadding(0) + } + + settleFocusedCalculator(proxy) + } + + private func applyFirstCalculatorFocusPadding(_ proxy: ScrollViewProxy) { + let bottomGap: CGFloat + + if let calculatorFrame { + let isExpandedFrame = calculatorFrame.height > Self.numberPadEstimatedHeight + let currentStackBottom = isExpandedFrame ? calculatorFrame.maxY : calculatorFrame.maxY + Self.numberPadEstimatedHeight + bottomGap = focusBottomY - currentStackBottom + } else if let numberPadFrame { + bottomGap = focusBottomY - numberPadFrame.maxY + } else { + return + } + + didApplyFirstCalculatorFocusPadding = true + setFirstCalculatorTopPadding(max(0, bottomGap)) + } + + private func settleFocusedCalculator( + _ proxy: ScrollViewProxy, + numberPadFrame proposedNumberPadFrame: CGRect? = nil + ) { + guard calculatorInput.isPresented else { return } + guard let numberPadFrame = proposedNumberPadFrame ?? numberPadFrame + else { return } + + if isCalculatorFirst { + if !didApplyFirstCalculatorFocusPadding { + startFocusedCalculatorTransition(proxy) + return } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.top, ScreenLayout.topPaddingWithSafeArea) - .padding(.bottom, bottomPadding) - .padding(.horizontal) + + settleFirstCalculatorStack(numberPadFrame: numberPadFrame) + return + } else if firstCalculatorTopPadding > 0 { + setFirstCalculatorTopPadding(0) + } + + let overflow = numberPadFrame.maxY - focusBottomY + guard abs(overflow) > 1 else { + focusScrollState.delta = nil + return + } + + scrollFocusedCalculatorByBottomDelta(overflow) + } + + private func settleFirstCalculatorStack(numberPadFrame: CGRect) { + guard numberPadFrame.height >= Self.numberPadEstimatedHeight - 1 else { return } + + let bottomGap = focusBottomY - numberPadFrame.maxY + guard abs(bottomGap) > 1 else { return } + + setFirstCalculatorTopPadding(max(0, firstCalculatorTopPadding + bottomGap)) + } + + private func scrollFocusedCalculatorByBottomDelta(_ delta: CGFloat) { + guard let scrollView else { return } + + scrollView.layoutIfNeeded() + + let baseOffsetY = focusScrollState.targetY ?? scrollView.contentOffset.y + let proposedTargetY = baseOffsetY + delta + let maxOffsetY = max(0, scrollView.contentSize.height - scrollView.bounds.height + scrollView.adjustedContentInset.bottom) + let targetY = min(maxOffsetY, max(0, proposedTargetY)) + let previousTargetY = focusScrollState.targetY ?? scrollView.contentOffset.y + let appliedDelta = targetY - previousTargetY + let residualDelta = delta - appliedDelta + + if let previousDelta = focusScrollState.delta, abs(delta - previousDelta) <= 1 { + scheduleFocusedCalculatorScrollRetry(residualDelta) + return } - // Dismiss (calculator widget) keyboard when scrolling - .scrollDismissesKeyboard(.interactively) + + guard abs(appliedDelta) > 1 else { + scheduleFocusedCalculatorScrollRetry(residualDelta) + return + } + + focusScrollState.delta = delta + focusScrollState.targetY = targetY + + UIView.animate( + withDuration: Self.focusAnimationDuration, + delay: 0, + options: [.beginFromCurrentState, .curveEaseOut, .allowUserInteraction] + ) { + scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: targetY), animated: false) + } completion: { _ in + scheduleFocusedCalculatorScrollRetry(residualDelta) + } + } + + private func scheduleFocusedCalculatorScrollRetry(_ delta: CGFloat) { + guard calculatorInput.isPresented, + !isCalculatorFirst, + abs(delta) > 1, + focusScrollState.retryCount < Self.maxFocusScrollRetries + else { return } + + focusScrollState.retryCount += 1 + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { + guard calculatorInput.isPresented, !isCalculatorFirst else { return } + + focusScrollState.delta = nil + scrollFocusedCalculatorByBottomDelta(delta) + } + } + + private func setFirstCalculatorTopPadding(_ value: CGFloat) { + guard abs(value - firstCalculatorTopPadding) > 1 else { return } + + withAnimation(Self.focusAnimation) { + firstCalculatorTopPadding = value + } + } + + private var focusBottomY: CGFloat { + UIScreen.main.bounds.height } + @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 CalculatorFocusScrollState { + var delta: CGFloat? + var retryCount = 0 + var targetY: CGFloat? + + func reset(targetY: CGFloat? = nil) { + delta = nil + retryCount = 0 + self.targetY = targetY + } +} + +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) + ) + } + } + } +} + +private struct ScrollViewResolver: UIViewRepresentable { + let onResolve: (UIScrollView?) -> Void + + func makeUIView(context _: Context) -> UIView { + let view = UIView(frame: .zero) + resolve(from: view) + return view + } + + func updateUIView(_ uiView: UIView, context _: Context) { + resolve(from: uiView) + } + + private func resolve(from view: UIView) { + DispatchQueue.main.async { + onResolve(view.enclosingScrollView) + } + } +} + +private extension UIView { + var enclosingScrollView: UIScrollView? { + var candidate = superview + + while let view = candidate { + if let scrollView = view as? UIScrollView { + return scrollView + } + + candidate = view.superview + } + + return nil } } 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..61b4f2338 --- /dev/null +++ b/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift @@ -0,0 +1,204 @@ +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 + + @State private var carouselPage: Int = 0 + @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 + + sizeLabel + + 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() + } + .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) { + 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) + .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 = CalculatorHomeScreenWidgetOptionsStore.load() + let savedSats = CalculatorWidgetFormatter.bitcoinValueToSats(saved.bitcoinValue, displayUnit: saved.displayUnit) + let bitcoinValue = saved.bitcoinValue.isEmpty + ? "" + : CalculatorWidgetFormatter.satsToBitcoinValue(savedSats, 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 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 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..98829b4a9 --- /dev/null +++ b/BitkitTests/CalculatorWidgetTests.swift @@ -0,0 +1,111 @@ +@testable import Bitkit +import XCTest + +final class CalculatorWidgetTests: XCTestCase { + func testModernBitcoinFormattingUsesSpaceGrouping() { + XCTAssertEqual( + CalculatorWidgetFormatter.formatBitcoinValue("1800000000", displayUnit: .modern), + "1 800 000 000" + ) + } + + func testFiatFormattingUsesCommaGroupingAndPlaceholderZero() { + XCTAssertEqual(CalculatorWidgetFormatter.formatFiatValue("82209.8"), "82,209.8") + XCTAssertEqual(CalculatorWidgetFormatter.formatFiatPlaceholder("82209.8"), "0") + } + + 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 testPersistedFiatOnlyValuesUseFiatAsSource() { + let values = CalculatorWidgetValues(bitcoinValue: "", fiatValue: "12.34") + + XCTAssertTrue(values.shouldRefreshBitcoinFromFiat) + } + + 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 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 testClassicBitcoinRejectsValuesAboveSupply() { + XCTAssertTrue(CalculatorWidgetFormatter.exceedsMaxBitcoin("21000000.00000001", displayUnit: .classic)) + XCTAssertFalse(CalculatorWidgetFormatter.exceedsMaxBitcoin("21000000", displayUnit: .classic)) + } +} diff --git a/changelog.d/next/calculator-widget-v61.added.md b/changelog.d/next/calculator-widget-v61.added.md new file mode 100644 index 000000000..671234ff2 --- /dev/null +++ b/changelog.d/next/calculator-widget-v61.added.md @@ -0,0 +1 @@ +Refreshed the Calculator widget with the v6.1 design and Bitkit number pad interaction.