From 87b302a6fc103043453e3ab63a9e521e85b16604 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 20 May 2026 20:00:57 +0200 Subject: [PATCH 1/2] feat: calculator v61 redesign --- Bitkit/AppScene.swift | 2 + Bitkit/Components/Header.swift | 8 +- Bitkit/Components/NumberPad.swift | 33 +- Bitkit/Components/TabBar/TabBar.swift | 5 + Bitkit/Components/Widgets/BaseWidget.swift | 174 +++--- .../Components/Widgets/CalculatorWidget.swift | 581 ++++++++++-------- Bitkit/MainNavView.swift | 7 +- Bitkit/Managers/CalculatorInputManager.swift | 45 ++ Bitkit/Models/CalculatorWidgetData.swift | 360 +++++++++++ Bitkit/Models/Currency.swift | 2 +- .../Localization/en.lproj/Localizable.strings | 3 + ...lculatorHomeScreenWidgetOptionsStore.swift | 24 + Bitkit/Views/Home/HomeWidgetsView.swift | 137 ++++- Bitkit/Views/HomeScreen.swift | 34 + .../Widgets/CalculatorWidgetPreviewView.swift | 200 ++++++ BitkitTests/CalculatorWidgetTests.swift | 76 +++ .../next/calculator-widget-v61.added.md | 1 + 17 files changed, 1326 insertions(+), 366 deletions(-) create mode 100644 Bitkit/Managers/CalculatorInputManager.swift create mode 100644 Bitkit/Models/CalculatorWidgetData.swift create mode 100644 Bitkit/Services/Widgets/CalculatorHomeScreenWidgetOptionsStore.swift create mode 100644 Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift create mode 100644 BitkitTests/CalculatorWidgetTests.swift create mode 100644 changelog.d/next/calculator-widget-v61.added.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 90a005598..d2bc6cb81 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -30,6 +30,7 @@ struct AppScene: View { @StateObject private var pubkyProfile = PubkyProfileManager() @StateObject private var contactsManager = ContactsManager() @State private var keyboardManager = KeyboardManager() + @State private var calculatorInputManager = CalculatorInputManager() @State private var hideSplash = false @State private var removeSplash = false @@ -135,6 +136,7 @@ struct AppScene: View { .environmentObject(pubkyProfile) .environmentObject(contactsManager) .environment(keyboardManager) + .environment(calculatorInputManager) .onChange(of: pubkyProfile.authState, initial: true) { _, authState in if authState == .authenticated, let pk = pubkyProfile.publicKey { Task { try? await contactsManager.loadContacts(for: pk) } diff --git a/Bitkit/Components/Header.swift b/Bitkit/Components/Header.swift index 8d962d98b..9278d86e7 100644 --- a/Bitkit/Components/Header.swift +++ b/Bitkit/Components/Header.swift @@ -1,6 +1,7 @@ import SwiftUI struct Header: View { + @Environment(CalculatorInputManager.self) private var calculatorInput @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var pubkyProfile: PubkyProfileManager @@ -25,12 +26,14 @@ struct Header: View { AppStatus( testID: "HeaderAppStatus", onPress: { + calculatorInput.dismiss() navigation.navigate(.appStatus) } ) if showWidgetEditButton { Button(action: { + calculatorInput.dismiss() isEditingWidgets.toggle() }) { Image(isEditingWidgets ? "check-mark" : "pencil") @@ -45,6 +48,8 @@ struct Header: View { } Button { + calculatorInput.dismiss() + withAnimation { app.showDrawer = true } @@ -65,9 +70,10 @@ struct Header: View { .padding(.trailing, 10) } - @ViewBuilder private var profileButton: some View { Button { + calculatorInput.dismiss() + if pubkyProfile.isAuthenticated || pubkyProfile.cachedName != nil { navigation.navigate(.profile) } else if pubkyProfile.initializationErrorMessage != nil { 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..2544b42ae 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 @@ -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 3012d9ccf..9839c0920 100644 --- a/Bitkit/Components/Widgets/BaseWidget.swift +++ b/Bitkit/Components/Widgets/BaseWidget.swift @@ -125,104 +125,104 @@ struct BaseWidget: View { } var body: some View { - Button {} label: { - VStack(spacing: 0) { - if type == .suggestions ? isEditing : (settings.showWidgetTitles || isEditing) { - HStack { - HStack(spacing: 16) { - Image(metadata.icon) - .resizable() - .frame(width: 32, height: 32) + widgetContent + .accessibilityIdentifier("\(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 type == .suggestions ? isEditing : (settings.showWidgetTitles || 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") - // Add spacer only when showing title and not editing - if settings.showWidgetTitles && !isEditing { - Spacer() - .frame(height: 16) + 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 + // Add spacer only when showing title and not editing + if settings.showWidgetTitles && !isEditing { + Spacer() + .frame(height: 16) } } - .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 @@ -236,14 +236,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..262a88df6 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 @@ -74,266 +26,385 @@ struct CalculatorWidget: View { 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) { + 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 - } - } } } - .onAppear { - // Initialize fiat amount on first load - if fiatAmount.isEmpty { - updateFiatAmount(from: bitcoinAmount) - } + .task { + hydrateValuesIfNeeded() } .onChange(of: currency.selectedCurrency) { - // Update fiat amount when currency changes - updateFiatAmount(from: bitcoinAmount) + refreshCurrencyFields() + refreshFiatFromBitcoin() + refreshNumberPadConfiguration() + persistValues() + } + .onChange(of: currency.displayUnit) { _, newUnit in + convertBitcoinValue(to: newUnit) + refreshCurrencyFields() + refreshFiatFromBitcoin() + refreshNumberPadConfiguration() + persistValues() } + .onChange(of: currency.rates) { + refreshCurrencyFields() + refreshFiatFromBitcoin() + persistValues() + } + .onChange(of: calculatorInput.submittedKey?.id) { + guard let key = calculatorInput.submittedKey?.value else { return } + handleNumberPadInput(key) + } + } + + private var currentValues: CalculatorWidgetValues { + CalculatorWidgetValues( + bitcoinValue: values.bitcoinValue, + fiatValue: values.fiatValue, + displayUnit: currency.displayUnit, + currencySymbol: currency.symbol, + selectedCurrency: currency.selectedCurrency + ) + } + + 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 + + refreshFiatFromBitcoin() + persistValues() } - /// Updates fiat amount based on bitcoin input - private func updateFiatAmount(from bitcoin: String) { - // Sanitize bitcoin input - let sanitizedBitcoin = sanitizeBitcoinInput(bitcoin) + private func selectInput(_ input: CalculatorMoneyType) { + calculatorInput.activate( + input, + numberPadType: numberPadType(for: input), + decimalSeparator: CalculatorWidgetFormatter.decimalSeparator() + ) + } + + 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 = UInt64(sanitizedBitcoin), amount > 0 else { - fiatAmount = "" + guard nextValue != currentValue || key == "delete" || key == "clear" else { + showInputError(for: key) return } - // Cap the amount at maximum bitcoin - let cappedAmount = min(amount, MAX_BITCOIN) - - // Convert to fiat - if let converted = currency.convert(sats: cappedAmount) { - fiatAmount = formatFiatAmount(converted.value) - } else { - fiatAmount = "" + if activeInput == .bitcoin, + CalculatorWidgetFormatter.exceedsMaxBitcoin(nextValue, displayUnit: currency.displayUnit) + { + showInputError(for: key) + return } - // Update bitcoin amount if it was capped or needs formatting - let formattedBitcoin = formatNumberWithSeparators(String(cappedAmount)) - if formattedBitcoin != bitcoin { - bitcoinAmount = formattedBitcoin + calculatorInput.errorKey = nil + + switch activeInput { + case .bitcoin: + values.bitcoinValue = nextValue + refreshFiatFromBitcoin() + case .fiat: + values.fiatValue = nextValue + refreshBitcoinFromFiat() } + + persistValues() } - /// Updates bitcoin amount based on fiat input - private func updateBitcoinAmount(from fiat: String) { - // Sanitize fiat input - let sanitizedFiat = sanitizeFiatInput(fiat) + private func rawValue(for input: CalculatorMoneyType) -> String { + switch input { + case .bitcoin: + return values.bitcoinValue + case .fiat: + return values.fiatValue + } + } - guard let amount = Double(sanitizedFiat), amount > 0 else { - bitcoinAmount = "" - return + private func numberPadType(for input: CalculatorMoneyType) -> NumberPadType { + switch input { + case .bitcoin where currency.displayUnit == .modern: + return .integer + default: + return .decimal } + } - // Convert to sats - if let convertedSats = currency.convert(fiatAmount: amount) { - // Cap the amount at maximum bitcoin - let cappedSats = min(convertedSats, MAX_BITCOIN) + 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 + } + } - bitcoinAmount = formatNumberWithSeparators(String(cappedSats)) + private func refreshNumberPadConfiguration() { + guard let activeInput = calculatorInput.activeInput else { return } + calculatorInput.updateConfiguration( + numberPadType: numberPadType(for: activeInput), + decimalSeparator: CalculatorWidgetFormatter.decimalSeparator() + ) + } - // Update fiat amount if bitcoin was capped - if cappedSats != convertedSats { - if let converted = currency.convert(sats: cappedSats) { - fiatAmount = formatFiatAmount(converted.value) - } - } - } else { - bitcoinAmount = "" - } + private func refreshCurrencyFields() { + values.displayUnit = currency.displayUnit + values.currencySymbol = currency.symbol + values.selectedCurrency = currency.selectedCurrency } - /// 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 convertBitcoinValue(to newUnit: BitcoinDisplayUnit) { + guard previousDisplayUnit != newUnit else { return } + + let sats = CalculatorWidgetFormatter.bitcoinValueToSats(values.bitcoinValue, displayUnit: previousDisplayUnit) + values.bitcoinValue = CalculatorWidgetFormatter.satsToBitcoinValue(sats, displayUnit: newUnit) + previousDisplayUnit = newUnit } - /// 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 refreshFiatFromBitcoin() { + guard !values.bitcoinValue.isEmpty else { + values.fiatValue = "" + return } - 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 + let sats = CalculatorWidgetFormatter.bitcoinValueToSats(values.bitcoinValue, displayUnit: currency.displayUnit) + if sats == 0 { + values.fiatValue = "0.00" + return } - return processed.replacingOccurrences(of: "^0+(?=\\d)", with: "", options: .regularExpression) + if let converted = currency.convert(sats: sats) { + values.fiatValue = CalculatorWidgetFormatter.fiatRawValue(from: converted.value) + } else { + values.fiatValue = "" + } } - /// 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: ".") - - let integer = components[0] - let formattedInteger = integer.replacingOccurrences(of: "\\B(?=(\\d{3})+(?!\\d))", with: " ", options: .regularExpression) + private func refreshBitcoinFromFiat() { + guard !values.fiatValue.isEmpty else { + values.bitcoinValue = "" + return + } - if components.count > 1 { - return formattedInteger + "." + components[1] + let fiatValue = CalculatorWidgetFormatter.fiatDecimalValue(values.fiatValue) + if NSDecimalNumber(decimal: fiatValue).compare(NSDecimalNumber.zero) == .orderedSame { + values.bitcoinValue = currency.displayUnit == .modern ? "0" : "0" + return } - return endsWithDecimal ? formattedInteger + "." : formattedInteger + let fiatDouble = NSDecimalNumber(decimal: fiatValue).doubleValue + if let sats = currency.convert(fiatAmount: fiatDouble) { + let cappedSats = min(sats, CalculatorWidgetFormatter.maxBitcoinSats) + values.bitcoinValue = CalculatorWidgetFormatter.satsToBitcoinValue(cappedSats, displayUnit: currency.displayUnit) + } else { + values.bitcoinValue = "" + } } - /// 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 = " " - - return formatter.string(from: value as NSDecimalNumber) ?? "0.00" + private func persistValues() { + CalculatorHomeScreenWidgetOptionsStore.save(currentValues) } - /// 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 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) - // 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] +struct CalculatorWidgetWideContent: View { + let values: CalculatorWidgetValues + var activeInput: CalculatorMoneyType? + var onSelectInput: ((CalculatorMoneyType) -> Void)? - // Pad decimal part to 2 digits - let paddedDecimal = decimal.padding(toLength: 2, withPad: "0", startingAt: 0) - return integer + "." + paddedDecimal + 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) } - } - return input + 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) + } + } } +} - /// 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: "") +// MARK: - Compact layout (small carousel page) - // Check if input matches valid pattern: digits, optional dot, up to 2 decimal digits - let validPattern = "^\\d*\\.?\\d{0,2}$" +struct CalculatorWidgetCompactContent: View { + let values: CalculatorWidgetValues - // Allow empty string, single dot, or "0." - if processed.isEmpty || processed == "." || processed == "0." { - return processed + 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)? - // 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 + var body: some View { + if let onTap { + Button(action: onTap) { + rowContent } - return processed + .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityIdentifier ?? "") + } else { + rowContent } - - // If invalid, return the previous valid value by removing the last character - return String(processed.dropLast()) } - /// 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) + } - // If invalid, return the previous valid value by removing the last character - return String(input.dropLast()) + private func isVisible(at date: Date) -> Bool { + Int(date.timeIntervalSince1970 * 2) % 2 == 0 } } @@ -341,6 +412,7 @@ struct CalculatorWidget: View { CalculatorWidget() .padding() .background(Color.black) + .environment(CalculatorInputManager()) .environmentObject(CurrencyViewModel()) .preferredColorScheme(.dark) } @@ -349,6 +421,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 a787d4e9f..9e4d36dfa 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -431,7 +431,12 @@ struct MainNavView: View { // Widgets case .widgetsIntro: WidgetsIntroView() case .widgetsList: WidgetsListView() - case let .widgetDetail(widgetType): WidgetDetailView(id: widgetType) + case let .widgetDetail(widgetType): + if widgetType == .calculator { + CalculatorWidgetPreviewView() + } else { + WidgetDetailView(id: widgetType) + } case let .widgetEdit(widgetType): WidgetEditView(id: widgetType) // Settings 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..89624be03 --- /dev/null +++ b/Bitkit/Models/CalculatorWidgetData.swift @@ -0,0 +1,360 @@ +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 + } +} + +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 nextValue: String = switch key { + 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 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 f51f1f796..af17b4e98 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 dc51a52c1..959982727 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1390,6 +1390,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 fea1bcf9d..bd56c5368 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 @@ -10,6 +11,7 @@ struct HomeWidgetsView: View { @EnvironmentObject var widgets: WidgetsViewModel @Binding var isEditingWidgets: Bool + @State private var calculatorFrame: CGRect = .zero private var bottomPadding: CGFloat { // Keep the calculator widget fully scrollable above the keyboard. @@ -26,13 +28,27 @@ 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)) + } + + private var shouldAnchorCalculator: Bool { + calculatorInput.isPresented && widgetsToShow.first?.type == .calculator + } + var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { DraggableList( - widgetsToShow, + visibleWidgets, id: \.id, - enableDrag: isEditingWidgets, + enableDrag: isEditingWidgets && !calculatorInput.isPresented, itemHeight: 80, onReorder: { sourceIndex, destinationIndex in widgets.reorderWidgets(from: sourceIndex, to: destinationIndex) @@ -40,32 +56,123 @@ struct HomeWidgetsView: View { ) { widget in rowContent(widget) } - .id(widgetsToShow.map(\.id)) + .id(visibleWidgets.map(\.id)) + + if !calculatorInput.isPresented { + CustomButton(title: t("widgets__add"), variant: .tertiary) { + calculatorInput.dismiss() - CustomButton(title: t("widgets__add"), variant: .tertiary) { - if app.hasSeenWidgetsIntro { - navigation.navigate(.widgetsList) - } else { - navigation.navigate(.widgetsIntro) + if app.hasSeenWidgetsIntro { + navigation.navigate(.widgetsList) + } else { + navigation.navigate(.widgetsIntro) + } } + .padding(.top, 16) + .accessibilityIdentifier("WidgetsAdd") } - .padding(.top, 16) - .accessibilityIdentifier("WidgetsAdd") } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .padding(.top, ScreenLayout.topPaddingWithSafeArea) .padding(.bottom, bottomPadding) .padding(.horizontal) + .background { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + calculatorInput.dismiss() + } + } } + .scrollDisabled(calculatorInput.isPresented) + .simultaneousGesture( + DragGesture(minimumDistance: 8).onChanged { _ in + if calculatorInput.isPresented { + calculatorInput.dismiss() + } + } + ) // Dismiss (calculator widget) keyboard when scrolling .scrollDismissesKeyboard(.interactively) + .onPreferenceChange(CalculatorWidgetFramePreferenceKey.self) { frame in + if let frame, !calculatorInput.isPresented { + calculatorFrame = frame + } + } + .onDisappear { + calculatorInput.dismiss() + } + } + + private var anchoredCalculatorTopPadding: CGFloat { + guard shouldAnchorCalculator else { return 0 } + + let bottomInset = windowSafeAreaInsets.bottom > 0 ? windowSafeAreaInsets.bottom : 16 + let numberPadTop = UIScreen.main.bounds.height - bottomInset - NumberPad.contentHeight + let preferredGap: CGFloat = 16 + let calculatorHeight = calculatorFrame.height > 0 ? calculatorFrame.height : 144 + return max(0, numberPadTop - ScreenLayout.topPaddingWithSafeArea - calculatorHeight - preferredGap) } + @ViewBuilder private func rowContent(_ widget: Widget) -> some View { - widget.view( - widgetsViewModel: widgets, - isEditing: isEditingWidgets, - onEditingEnd: { withAnimation { isEditingWidgets = false } } - ) + if widget.type == .calculator { + VStack(spacing: 0) { + Color.clear + .frame(height: anchoredCalculatorTopPadding) + .animation(.easeInOut(duration: 0.2), value: anchoredCalculatorTopPadding) + + 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 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..6c933ca4e 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 @@ -57,6 +62,11 @@ struct HomeScreen: View { } .ignoresSafeArea() + if calculatorInput.isPresented { + calculatorNumberPad + .zIndex(10) + } + // Top and bottom gradients VStack(spacing: 0) { LinearGradient( @@ -86,4 +96,28 @@ struct HomeScreen: View { TimedSheetManager.shared.onHomeScreenExited() } } + + private var calculatorNumberPad: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 0) { + Divider() + + 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 ? 0 : 16) + .background(Color.black.ignoresSafeArea(edges: .bottom)) + } + } } diff --git a/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift b/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift new file mode 100644 index 000000000..e32c7f70f --- /dev/null +++ b/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift @@ -0,0 +1,200 @@ +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: fiatValue(for: bitcoinValue), + displayUnit: currency.displayUnit, + currencySymbol: currency.symbol, + selectedCurrency: currency.selectedCurrency + ) + } + + 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 CalculatorHomeScreenWidgetOptionsStore.load().fiatValue + } + 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..4c30a3a41 --- /dev/null +++ b/BitkitTests/CalculatorWidgetTests.swift @@ -0,0 +1,76 @@ +@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 testCurrencySymbolFallsBackToFirstCharacterForLongSymbols() { + XCTAssertEqual(CalculatorWidgetFormatter.displaySymbol("CHF"), "C") + XCTAssertEqual(CalculatorWidgetFormatter.displaySymbol("$"), "$") + } + + func testClassicBitcoinConvertsToSats() { + XCTAssertEqual( + CalculatorWidgetFormatter.bitcoinValueToSats("0.00010000", displayUnit: .classic), + 10000 + ) + } + + 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. From 86cc09bb2cf8eedd50fa3c37885147695b7a5b9c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 20 May 2026 20:15:49 +0200 Subject: [PATCH 2/2] fix: keep calculator fiat values in sync --- Bitkit/Components/Widgets/CalculatorWidget.swift | 8 ++++++++ Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Bitkit/Components/Widgets/CalculatorWidget.swift b/Bitkit/Components/Widgets/CalculatorWidget.swift index 262a88df6..78aea1757 100644 --- a/Bitkit/Components/Widgets/CalculatorWidget.swift +++ b/Bitkit/Components/Widgets/CalculatorWidget.swift @@ -223,6 +223,14 @@ struct CalculatorWidget: View { if let sats = currency.convert(fiatAmount: fiatDouble) { let cappedSats = min(sats, CalculatorWidgetFormatter.maxBitcoinSats) values.bitcoinValue = CalculatorWidgetFormatter.satsToBitcoinValue(cappedSats, displayUnit: currency.displayUnit) + + if cappedSats != sats { + if let cappedFiat = currency.convert(sats: cappedSats) { + values.fiatValue = CalculatorWidgetFormatter.fiatRawValue(from: cappedFiat.value) + } else { + values.fiatValue = "" + } + } } else { values.bitcoinValue = "" } diff --git a/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift b/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift index e32c7f70f..f6c3d2e74 100644 --- a/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift @@ -173,7 +173,7 @@ struct CalculatorWidgetPreviewView: View { let sats = CalculatorWidgetFormatter.bitcoinValueToSats(bitcoinValue, displayUnit: currency.displayUnit) if sats == 0 { return "0.00" } guard let converted = currency.convert(sats: sats) else { - return CalculatorHomeScreenWidgetOptionsStore.load().fiatValue + return "" } return CalculatorWidgetFormatter.fiatRawValue(from: converted.value) }