From 6c2d065d16f0e3b6c06a2fbf1cac468942246252 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 17 May 2026 00:29:42 +0200 Subject: [PATCH 1/4] feat: add calculator v61 app & OS widget --- Bitkit.xcodeproj/project.pbxproj | 3 + Bitkit/Components/NumberPad.swift | 6 +- .../Components/Widgets/CalculatorWidget.swift | 564 ++++++++++-------- Bitkit/MainNavView.swift | 2 + Bitkit/Models/CalculatorWidgetData.swift | 368 ++++++++++++ Bitkit/Models/Currency.swift | 2 +- .../Localization/en.lproj/Localizable.strings | 1 + ...lculatorHomeScreenWidgetOptionsStore.swift | 36 ++ .../Widgets/CalculatorWidgetPreviewView.swift | 199 ++++++ BitkitTests/CalculatorWidgetTests.swift | 61 ++ BitkitWidget/BitkitWidget.swift | 1 + BitkitWidget/CalculatorHomeScreenWidget.swift | 175 ++++++ .../next/calculator-widget-v61.added.md | 1 + 13 files changed, 1163 insertions(+), 256 deletions(-) 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 BitkitWidget/CalculatorHomeScreenWidget.swift create mode 100644 changelog.d/next/calculator-widget-v61.added.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index e7c80f00c..f8c087ac9 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -194,11 +194,14 @@ Models/BlocksWidgetFields.swift, Models/BlocksWidgetOptions.swift, Models/BitcoinFacts.swift, + Models/CalculatorWidgetData.swift, + Models/Currency.swift, Models/NewsWidgetData.swift, Models/NewsWidgetOptions.swift, Models/PriceWidgetData.swift, Models/PriceWidgetOptions.swift, Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift, + Services/Widgets/CalculatorHomeScreenWidgetOptionsStore.swift, Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift, Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift, Styles/Colors.swift, diff --git a/Bitkit/Components/NumberPad.swift b/Bitkit/Components/NumberPad.swift index 208f8f9bf..5b6495638 100644 --- a/Bitkit/Components/NumberPad.swift +++ b/Bitkit/Components/NumberPad.swift @@ -8,11 +8,13 @@ enum NumberPadType { struct NumberPad: View { let type: NumberPadType + let decimalSeparator: String let errorKey: String? let onPress: (String) -> Void - init(type: NumberPadType = .simple, errorKey: String? = nil, onPress: @escaping (String) -> Void) { + init(type: NumberPadType = .simple, decimalSeparator: String = ".", errorKey: String? = nil, onPress: @escaping (String) -> Void) { self.type = type + self.decimalSeparator = decimalSeparator self.errorKey = errorKey self.onPress = onPress } @@ -59,7 +61,7 @@ struct NumberPad: View { } case .decimal: NumberPadButton( - text: ".", + text: decimalSeparator, height: buttonHeight, hasError: errorKey == ".", testID: "NDecimal" diff --git a/Bitkit/Components/Widgets/CalculatorWidget.swift b/Bitkit/Components/Widgets/CalculatorWidget.swift index 1e50d0719..36295e0cd 100644 --- a/Bitkit/Components/Widgets/CalculatorWidget.swift +++ b/Bitkit/Components/Widgets/CalculatorWidget.swift @@ -1,65 +1,18 @@ 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 @EnvironmentObject private var currency: CurrencyViewModel - /// Bitcoin amount state (stored as string to preserve user input) - @State private var bitcoinAmount: String = "10000" - - /// Fiat amount state (stored as string to preserve user input) - @State private var fiatAmount: String = "" - - /// Focus state for text fields - @FocusState private var focusedField: FocusedField? - - private enum FocusedField { - case bitcoin, fiat - } + @State private var values = CalculatorWidgetValues() + @State private var activeInput: CalculatorMoneyType? + @State private var errorKey: String? + @State private var hasHydrated = false + @State private var previousDisplayUnit: BitcoinDisplayUnit = .modern - /// Initialize the widget init( isEditing: Bool = false, onEditingEnd: (() -> Void)? = nil @@ -74,266 +27,371 @@ 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) - } - } + VStack(spacing: 0) { + CalculatorWidgetWideContent( + values: currentValues, + activeInput: activeInput, + onSelectInput: selectInput ) - .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) - } - } - ) - .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 let activeInput { + NumberPad( + type: numberPadType(for: activeInput), + decimalSeparator: CalculatorWidgetFormatter.decimalSeparator(), + errorKey: errorKey, + onPress: handleNumberPadInput + ) + .padding(.top, 8) + .transition(.opacity.combined(with: .move(edge: .bottom))) + .accessibilityIdentifier("CalculatorNumberPad") } } + .animation(.easeInOut(duration: 0.2), value: activeInput) } - .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() + persistValues() + } + .onChange(of: currency.displayUnit) { _, newUnit in + convertBitcoinValue(to: newUnit) + refreshCurrencyFields() + refreshFiatFromBitcoin() + persistValues() + } + .onChange(of: currency.rates) { + refreshCurrencyFields() + refreshFiatFromBitcoin() + persistValues() + } + .onDisappear { + activeInput = nil } } - /// Updates fiat amount based on bitcoin input - private func updateFiatAmount(from bitcoin: String) { - // Sanitize bitcoin input - let sanitizedBitcoin = sanitizeBitcoinInput(bitcoin) + private var currentValues: CalculatorWidgetValues { + CalculatorWidgetValues( + bitcoinValue: values.bitcoinValue, + fiatValue: values.fiatValue, + displayUnit: currency.displayUnit, + currencySymbol: currency.symbol, + selectedCurrency: currency.selectedCurrency + ) + } - guard let amount = UInt64(sanitizedBitcoin), amount > 0 else { - fiatAmount = "" - return + 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 + + if values.bitcoinValue.isEmpty, saved.bitcoinValue.isEmpty { + values.bitcoinValue = CalculatorWidgetValues().bitcoinValue } - // 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 = "" - } + refreshFiatFromBitcoin() + 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) { + activeInput = input + errorKey = nil } - /// 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 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" 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)) + 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 } + } - 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 refreshFiatFromBitcoin() { + guard !values.bitcoinValue.isEmpty else { + values.fiatValue = "" + return } - return endsWithDecimal ? formattedInteger + "." : formattedInteger + let sats = CalculatorWidgetFormatter.bitcoinValueToSats(values.bitcoinValue, displayUnit: currency.displayUnit) + if sats == 0 { + values.fiatValue = "0.00" + return + } + + if let converted = currency.convert(sats: sats) { + values.fiatValue = CalculatorWidgetFormatter.fiatRawValue(from: converted.value) + } } - /// 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 refreshBitcoinFromFiat() { + guard !values.fiatValue.isEmpty else { + values.bitcoinValue = "" + return + } - return formatter.string(from: value as NSDecimalNumber) ?? "0.00" + let fiatValue = CalculatorWidgetFormatter.fiatDecimalValue(values.fiatValue) + if NSDecimalNumber(decimal: fiatValue).compare(NSDecimalNumber.zero) == .orderedSame { + values.bitcoinValue = currency.displayUnit == .modern ? "0" : "0" + return + } + + 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) + } } - /// 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 persistValues() { + CalculatorHomeScreenWidgetOptionsStore.save(currentValues) + CalculatorHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() + } + + private func showInputError(for key: String) { + Haptics.notify(.warning) + errorKey = key + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + errorKey = nil } + } +} - // 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] +// MARK: - Wide layout (in-app + carousel page + .systemMedium OS widget) - // Pad decimal part to 2 digits - let paddedDecimal = decimal.padding(toLength: 2, withPad: "0", startingAt: 0) - return integer + "." + paddedDecimal +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 + ) { + onSelectInput?(.bitcoin) } + .accessibilityIdentifier("CalculatorBtcInput") + + 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 + ) { + onSelectInput?(.fiat) + } + .accessibilityIdentifier("CalculatorFiatInput") } - - return input } +} - /// 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 + .systemSmall OS widget) - // 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 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 { + 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) } - return processed - } + .frame(width: iconSize, height: iconSize) - // If invalid, return the previous valid value by removing the last character - return String(processed.dropLast()) - } + HStack(spacing: 0) { + Text(displayValue) + .font(Fonts.semiBold(size: 17)) + .foregroundColor(value.isEmpty ? .white50 : .textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) + + if isActive { + CalculatorCursor() + } + + if !placeholder.isEmpty { + Text(placeholder) + .font(Fonts.semiBold(size: 17)) + .foregroundColor(.white50) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .clipped() - /// Validates bitcoin input to ensure only numbers and spaces - private func validateBitcoinInput(_ input: String) -> String { - // Allow empty input - if input.isEmpty { - return input + 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()) + .onTapGesture { + onTap?() } + } - // 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 } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 023305007..ab153047c 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -447,6 +447,8 @@ struct MainNavView: View { BlocksWidgetPreviewView() case .facts: FactsWidgetPreviewView() + case .calculator: + CalculatorWidgetPreviewView() default: WidgetDetailView(id: widgetType) } diff --git a/Bitkit/Models/CalculatorWidgetData.swift b/Bitkit/Models/CalculatorWidgetData.swift new file mode 100644 index 000000000..4d9db8647 --- /dev/null +++ b/Bitkit/Models/CalculatorWidgetData.swift @@ -0,0 +1,368 @@ +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 "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 integer.reversed().chunked(into: groupSize).joined(separator: String(groupingSeparator)).reversedString + } + + private static func formatGroupedIntegerPreservingZeros(value: String, groupingSeparator: Character) -> String { + guard !value.isEmpty else { return "" } + return value.reversed().chunked(into: groupSize).joined(separator: String(groupingSeparator)).reversedString + } + + 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 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 ?? "." + } +} + +private extension BidirectionalCollection { + func chunked(into size: Int) -> [SubSequence] { + var chunks: [SubSequence] = [] + var currentEnd = endIndex + + while currentEnd != startIndex { + let start = index(currentEnd, offsetBy: -size, limitedBy: startIndex) ?? startIndex + chunks.append(self[start ..< currentEnd]) + currentEnd = start + } + + return chunks + } +} + +private extension Array where Element: Collection { + func joined(separator: String) -> String where Element.Element == Character { + map(String.init).joined(separator: separator) + } +} + +private extension String { + var reversedString: String { + String(reversed()) + } +} 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 a74556768..ec314c0f4 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1380,6 +1380,7 @@ "widgets__nav_title" = "Widgets"; "widgets__widget__nav_title" = "Widget"; "widgets__widget__edit" = "Widget Feed"; +"widgets__widget__edit_description" = "Customize how {name} appears in your widget feed."; "widgets__widget__edit_default" = "Default"; "widgets__widget__edit_custom" = "Custom"; "widgets__widget__source" = "Source"; diff --git a/Bitkit/Services/Widgets/CalculatorHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/CalculatorHomeScreenWidgetOptionsStore.swift new file mode 100644 index 000000000..05c6201e4 --- /dev/null +++ b/Bitkit/Services/Widgets/CalculatorHomeScreenWidgetOptionsStore.swift @@ -0,0 +1,36 @@ +import Foundation +import WidgetKit + +/// Mirrors the latest calculator values into the App Group so the WidgetKit extension can render them, +/// and centralizes the WidgetKit reload trigger for the Calculator home-screen widget. +enum CalculatorHomeScreenWidgetOptionsStore { + /// WidgetKit `kind` for the home-screen Calculator widget (must match `BitkitCalculatorWidget`). + static let calculatorHomeScreenWidgetKind = "BitkitCalculatorWidget" + + 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 + } + + /// Call after updating values so the home-screen widget timeline refreshes. + /// No-op when running inside the widget extension itself (`appex`). + static func reloadHomeScreenWidgetIfNeeded() { + guard Bundle.main.bundleURL.pathExtension != "appex" else { return } + WidgetCenter.shared.reloadTimelines(ofKind: calculatorHomeScreenWidgetKind) + } +} diff --git a/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift b/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift new file mode 100644 index 000000000..bf1d88788 --- /dev/null +++ b/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift @@ -0,0 +1,199 @@ +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 + ? CalculatorWidgetValues().bitcoinValue + : 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 { + 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..4287997f9 --- /dev/null +++ b/BitkitTests/CalculatorWidgetTests.swift @@ -0,0 +1,61 @@ +@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 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 + ) + } +} diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift index dfd5eb8f9..0c6dd1e75 100644 --- a/BitkitWidget/BitkitWidget.swift +++ b/BitkitWidget/BitkitWidget.swift @@ -8,5 +8,6 @@ struct BitkitWidgetBundle: WidgetBundle { BitkitNewsWidget() BitkitBlocksWidget() BitkitFactsWidget() + BitkitCalculatorWidget() } } diff --git a/BitkitWidget/CalculatorHomeScreenWidget.swift b/BitkitWidget/CalculatorHomeScreenWidget.swift new file mode 100644 index 000000000..b9abfebd6 --- /dev/null +++ b/BitkitWidget/CalculatorHomeScreenWidget.swift @@ -0,0 +1,175 @@ +import SwiftUI +import WidgetKit + +// MARK: - Entry + +struct CalculatorWidgetEntry: TimelineEntry { + let date: Date + let values: CalculatorWidgetValues +} + +// MARK: - Timeline Provider + +struct CalculatorWidgetProvider: TimelineProvider { + private static let refreshInterval: TimeInterval = 15 * 60 + + func placeholder(in _: Context) -> CalculatorWidgetEntry { + CalculatorWidgetEntry(date: Date(), values: CalculatorWidgetValues(bitcoinValue: "10000", fiatValue: "6.25")) + } + + func getSnapshot(in _: Context, completion: @escaping (CalculatorWidgetEntry) -> Void) { + completion(entry(at: Date())) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + let nextRefresh = now.addingTimeInterval(Self.refreshInterval) + completion(Timeline(entries: [entry(at: now)], policy: .after(nextRefresh))) + } + + private func entry(at date: Date) -> CalculatorWidgetEntry { + CalculatorWidgetEntry( + date: date, + values: CalculatorHomeScreenWidgetOptionsStore.load() + ) + } +} + +// MARK: - View + +struct CalculatorHomeScreenWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode + + var entry: CalculatorWidgetProvider.Entry + + var body: some View { + content + .containerBackground(for: .widget) { backgroundView } + } + + @ViewBuilder + private var content: some View { + switch widgetFamily { + case .systemSmall: + compactLayout + default: + wideLayout + } + } + + private var compactLayout: some View { + VStack(spacing: 16) { + row( + symbol: "₿", + value: CalculatorWidgetFormatter.formatBitcoinValue(entry.values.bitcoinValue, displayUnit: entry.values.displayUnit), + iconSize: 24, + rowPadding: 12 + ) + + row( + symbol: entry.values.currencySymbol, + value: CalculatorWidgetFormatter.formatFiatValue(entry.values.fiatValue), + iconSize: 24, + rowPadding: 12 + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var wideLayout: some View { + VStack(spacing: 16) { + row( + symbol: "₿", + value: CalculatorWidgetFormatter.formatBitcoinValue(entry.values.bitcoinValue, displayUnit: entry.values.displayUnit), + label: "BITCOIN", + iconSize: 32, + rowPadding: 16 + ) + + row( + symbol: entry.values.currencySymbol, + value: CalculatorWidgetFormatter.formatFiatValue(entry.values.fiatValue), + label: entry.values.selectedCurrency.uppercased(), + iconSize: 32, + rowPadding: 16 + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func row(symbol: String, value: String, label: String? = nil, iconSize: CGFloat, rowPadding: CGFloat) -> some View { + HStack(alignment: .center, spacing: 8) { + ZStack { + Circle() + .fill(iconBackgroundColor) + + Text(CalculatorWidgetFormatter.displaySymbol(symbol)) + .font(Fonts.semiBold(size: iconSize >= 32 ? 17 : 15)) + .foregroundColor(iconTextColor) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(width: iconSize, height: iconSize) + + Text(value.isEmpty ? "0" : value) + .font(Fonts.semiBold(size: 17)) + .foregroundColor(textColor) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(maxWidth: .infinity, alignment: .leading) + .widgetAccentable() + + if let label { + Text(label) + .font(Fonts.bold(size: 13)) + .foregroundColor(secondaryTextColor) + .lineLimit(1) + } + } + .padding(rowPadding) + .frame(maxWidth: .infinity) + .background(rowBackgroundColor) + .cornerRadius(8) + } + + private var backgroundView: some View { + widgetRenderingMode == .fullColor ? Color.gray6 : Color.clear + } + + private var rowBackgroundColor: Color { + widgetRenderingMode == .fullColor ? .black : .clear + } + + private var iconBackgroundColor: Color { + widgetRenderingMode == .fullColor ? .gray6 : .primary.opacity(0.12) + } + + private var iconTextColor: Color { + widgetRenderingMode == .fullColor ? .brandAccent : .primary + } + + private var textColor: Color { + widgetRenderingMode == .fullColor ? .white : .primary + } + + private var secondaryTextColor: Color { + widgetRenderingMode == .fullColor ? .white64 : .secondary + } +} + +// MARK: - Widget Configuration + +struct BitkitCalculatorWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration( + kind: CalculatorHomeScreenWidgetOptionsStore.calculatorHomeScreenWidgetKind, + provider: CalculatorWidgetProvider() + ) { entry in + CalculatorHomeScreenWidgetEntryView(entry: entry) + } + .configurationDisplayName("widgets__calculator__name") + .description("widgets__calculator__description") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} 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..e48c7eb5e --- /dev/null +++ b/changelog.d/next/calculator-widget-v61.added.md @@ -0,0 +1 @@ +Refreshed the Calculator widget with the v6.1 design and added native iOS Home Screen widget support. From 76f4cc3403462f8488fbbd6804b53a428cb12c57 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 17 May 2026 19:31:18 +0200 Subject: [PATCH 2/4] fix: stabilize calculator grouping formatter --- Bitkit/Models/CalculatorWidgetData.swift | 48 ++++++++++-------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/Bitkit/Models/CalculatorWidgetData.swift b/Bitkit/Models/CalculatorWidgetData.swift index 4d9db8647..5ef9499da 100644 --- a/Bitkit/Models/CalculatorWidgetData.swift +++ b/Bitkit/Models/CalculatorWidgetData.swift @@ -256,12 +256,12 @@ enum CalculatorWidgetFormatter { guard !value.isEmpty else { return "" } let normalized = value.drop { $0 == "0" } let integer = normalized.isEmpty ? "0" : String(normalized) - return integer.reversed().chunked(into: groupSize).joined(separator: String(groupingSeparator)).reversedString + return formatGroupedDigits(integer, groupingSeparator: groupingSeparator) } private static func formatGroupedIntegerPreservingZeros(value: String, groupingSeparator: Character) -> String { guard !value.isEmpty else { return "" } - return value.reversed().chunked(into: groupSize).joined(separator: String(groupingSeparator)).reversedString + return formatGroupedDigits(value, groupingSeparator: groupingSeparator) } private static func formatGroupedDecimal(value: String, groupingSeparator: Character, decimalSeparator: Character) -> String { @@ -292,6 +292,23 @@ enum CalculatorWidgetFormatter { 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 } @@ -339,30 +356,3 @@ private enum DecimalFormatSymbols { return formatter.decimalSeparator ?? "." } } - -private extension BidirectionalCollection { - func chunked(into size: Int) -> [SubSequence] { - var chunks: [SubSequence] = [] - var currentEnd = endIndex - - while currentEnd != startIndex { - let start = index(currentEnd, offsetBy: -size, limitedBy: startIndex) ?? startIndex - chunks.append(self[start ..< currentEnd]) - currentEnd = start - } - - return chunks - } -} - -private extension Array where Element: Collection { - func joined(separator: String) -> String where Element.Element == Character { - map(String.init).joined(separator: separator) - } -} - -private extension String { - var reversedString: String { - String(reversed()) - } -} From 2f07d5c702427b62a46c28f462031232b0d3277d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 18 May 2026 00:06:58 +0200 Subject: [PATCH 3/4] fix: address calculator widget bot feedback --- Bitkit/Components/Widgets/CalculatorWidget.swift | 4 ---- Bitkit/Resources/Localization/cs.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/de.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/en.lproj/Localizable.strings | 1 + .../Resources/Localization/es-419.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/fr.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/nl.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/pl.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings | 1 + Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift | 3 ++- BitkitWidget/CalculatorHomeScreenWidget.swift | 2 +- 11 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Bitkit/Components/Widgets/CalculatorWidget.swift b/Bitkit/Components/Widgets/CalculatorWidget.swift index 36295e0cd..1f5c71ea0 100644 --- a/Bitkit/Components/Widgets/CalculatorWidget.swift +++ b/Bitkit/Components/Widgets/CalculatorWidget.swift @@ -100,10 +100,6 @@ struct CalculatorWidget: View { ) previousDisplayUnit = currency.displayUnit - if values.bitcoinValue.isEmpty, saved.bitcoinValue.isEmpty { - values.bitcoinValue = CalculatorWidgetValues().bitcoinValue - } - refreshFiatFromBitcoin() persistValues() } diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 96c46a14b..45445f833 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1161,6 +1161,7 @@ "widgets__facts__description" = "Pokaždé, když otevřete peněženku, objevte zábavná fakta o bitcoinu."; "widgets__calculator__name" = "Bitcoinová kalkulačka"; "widgets__calculator__description" = "Převeďte ₿ částky na {fiatSymbol} nebo naopak."; +"widgets__calculator__gallery_description" = "Převeďte ₿ částky na fiat měnu nebo naopak."; "widgets__weather__name" = "Počasí v bitcoinu"; "widgets__weather__description" = "Zjistěte, kdy je vhodná doba pro transakce v blockchainu bitcoinu."; "widgets__weather__condition__good__title" = "Příznivé podmínky"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 01512565b..441e38a21 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1158,6 +1158,7 @@ "widgets__facts__description" = "Entdecke Fun Facts über Bitcoin, jedes Mal wenn du dein Wallet öffnest."; "widgets__calculator__name" = "Bitcoin Rechner"; "widgets__calculator__description" = "Wandle ₿-Beträge in {fiatSymbol} und umgekehrt."; +"widgets__calculator__gallery_description" = "Wandle ₿-Beträge in Fiatwährung und umgekehrt."; "widgets__weather__name" = "Bitcoin Wetter"; "widgets__weather__description" = "Finde heraus, wann es eine gute Zeit ist, um auf der Bitcoin blockchain Überweisungen zu tätigen."; "widgets__weather__condition__good__title" = "Günstige Bedingungen"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index ec314c0f4..abad094f1 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1413,6 +1413,7 @@ "widgets__facts__description" = "Discover fun facts about Bitcoin, every time you open your wallet."; "widgets__calculator__name" = "Bitcoin Calculator"; "widgets__calculator__description" = "Convert ₿ amounts to {fiatSymbol} or vice versa."; +"widgets__calculator__gallery_description" = "Convert ₿ amounts to fiat currency or vice versa."; "widgets__suggestions__name" = "Bitkit Suggestions"; "widgets__suggestions__description" = "Discover everything Bitkit has to offer."; "widgets__weather__name" = "Bitcoin Weather"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index c8fe37de4..08cc0b4d9 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1173,6 +1173,7 @@ "widgets__facts__description" = "Descubra datos curiosos sobre Bitcoin, cada vez que abra su billetera."; "widgets__calculator__name" = "Calculadora Bitcoin"; "widgets__calculator__description" = "Convierta los importes ₿ en {fiatSymbol} o viceversa."; +"widgets__calculator__gallery_description" = "Convierta los importes ₿ a moneda fíat o viceversa."; "widgets__weather__name" = "Clima Bitcoin"; "widgets__weather__description" = "Sepa cuándo es un buen momento para realizar transacciones en la blockchain de Bitcoin."; "widgets__weather__condition__good__title" = "Condiciones favorables"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index f8012778f..a894ce4c0 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1187,6 +1187,7 @@ "widgets__facts__description" = "Découvrez des faits amusants sur Bitcoin, chaque fois que vous ouvrez votre portefeuille."; "widgets__calculator__name" = "Calculateur Bitcoin"; "widgets__calculator__description" = "Convertissez les montants en ₿ en {fiatSymbol} ou vice versa."; +"widgets__calculator__gallery_description" = "Convertissez les montants en ₿ en monnaie fiduciaire ou vice versa."; "widgets__weather__name" = "Météo Bitcoin"; "widgets__weather__description" = "Découvrez quel est le bon moment pour effectuer des transactions sur la blockchain Bitcoin."; "widgets__weather__condition__good__title" = "Conditions favorables"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 3336e7ca5..fd6a356ef 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1181,6 +1181,7 @@ "widgets__facts__description" = "Entdecke Fun Facts über Bitcoin, jedes Mal wenn du dein Wallet öffnest."; "widgets__calculator__name" = "Bitcoin Rechner"; "widgets__calculator__description" = "Wandle ₿-Beträge in {fiatSymbol} und umgekehrt."; +"widgets__calculator__gallery_description" = "Wandel ₿-bedragen om naar fiatvaluta en andersom."; "widgets__weather__name" = "Bitcoin Wetter"; "widgets__weather__description" = "Finde heraus, wann es eine gute Zeit ist, um auf der Bitcoin blockchain Überweisungen zu tätigen."; "widgets__weather__condition__good__title" = "Gunstige Omstandigheden"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 7f32e6a0d..204910699 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1188,6 +1188,7 @@ "widgets__facts__description" = "Odkrywaj ciekawostki o Bitcoinie za każdym razem, gdy otwierasz swój portfel."; "widgets__calculator__name" = "Bitcoinowy Kalkulator"; "widgets__calculator__description" = "Przelicz kwoty ₿ na {fiatSymbol} lub odwrotnie."; +"widgets__calculator__gallery_description" = "Przelicz kwoty ₿ na waluty fiat lub odwrotnie."; "widgets__weather__name" = "Bitcoinowa Pogoda"; "widgets__weather__description" = "Sprawdź, kiedy jest dobry moment na transakcję w sieci Bitcoin."; "widgets__weather__condition__good__title" = "Korzystne warunki"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 8bb6e73a8..8701c52e3 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1189,6 +1189,7 @@ "widgets__facts__description" = "Descubra fatos divertidos sobre o Bitcoin, sempre que abrir sua carteira."; "widgets__calculator__name" = "Calculadora de Bitcoin"; "widgets__calculator__description" = "Converta ₿ para {fiatSymbol} ou vice-versa."; +"widgets__calculator__gallery_description" = "Converta ₿ para moeda fiduciária ou vice-versa."; "widgets__weather__name" = "Tempo do Bitcoin"; "widgets__weather__description" = "Descubra quando é um bom momento para fazer transações na blockchain do Bitcoin."; "widgets__weather__condition__good__title" = "Condições favoráveis"; diff --git a/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift b/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift index bf1d88788..e32c7f70f 100644 --- a/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift +++ b/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift @@ -156,7 +156,7 @@ struct CalculatorWidgetPreviewView: View { let saved = CalculatorHomeScreenWidgetOptionsStore.load() let savedSats = CalculatorWidgetFormatter.bitcoinValueToSats(saved.bitcoinValue, displayUnit: saved.displayUnit) let bitcoinValue = saved.bitcoinValue.isEmpty - ? CalculatorWidgetValues().bitcoinValue + ? "" : CalculatorWidgetFormatter.satsToBitcoinValue(savedSats, displayUnit: currency.displayUnit) values = CalculatorWidgetValues( @@ -169,6 +169,7 @@ struct CalculatorWidgetPreviewView: View { } 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 { diff --git a/BitkitWidget/CalculatorHomeScreenWidget.swift b/BitkitWidget/CalculatorHomeScreenWidget.swift index b9abfebd6..14127b312 100644 --- a/BitkitWidget/CalculatorHomeScreenWidget.swift +++ b/BitkitWidget/CalculatorHomeScreenWidget.swift @@ -169,7 +169,7 @@ struct BitkitCalculatorWidget: Widget { CalculatorHomeScreenWidgetEntryView(entry: entry) } .configurationDisplayName("widgets__calculator__name") - .description("widgets__calculator__description") + .description("widgets__calculator__gallery_description") .supportedFamilies([.systemSmall, .systemMedium]) } } From 1178d7096006c8ef2d9bf8f7d55402d7d489c5d6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 18 May 2026 00:34:30 +0200 Subject: [PATCH 4/4] fix: clear stale calculator conversions --- Bitkit/Components/Widgets/CalculatorWidget.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Bitkit/Components/Widgets/CalculatorWidget.swift b/Bitkit/Components/Widgets/CalculatorWidget.swift index 1f5c71ea0..666425aed 100644 --- a/Bitkit/Components/Widgets/CalculatorWidget.swift +++ b/Bitkit/Components/Widgets/CalculatorWidget.swift @@ -202,6 +202,8 @@ struct CalculatorWidget: View { if let converted = currency.convert(sats: sats) { values.fiatValue = CalculatorWidgetFormatter.fiatRawValue(from: converted.value) + } else { + values.fiatValue = "" } } @@ -221,6 +223,8 @@ struct CalculatorWidget: View { if let sats = currency.convert(fiatAmount: fiatDouble) { let cappedSats = min(sats, CalculatorWidgetFormatter.maxBitcoinSats) values.bitcoinValue = CalculatorWidgetFormatter.satsToBitcoinValue(cappedSats, displayUnit: currency.displayUnit) + } else { + values.bitcoinValue = "" } }