Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ struct AppScene: View {
@StateObject private var navigation = NavigationViewModel()
@StateObject private var network = NetworkMonitor()
@StateObject private var sheets = SheetViewModel()
@StateObject private var wallet = WalletViewModel()
@StateObject private var wallet: WalletViewModel
@StateObject private var currency = CurrencyViewModel()
@StateObject private var blocktank = BlocktankViewModel()
@StateObject private var activity = ActivityListViewModel()
@StateObject private var activity: ActivityListViewModel
@StateObject private var feeEstimatesManager: FeeEstimatesManager
@StateObject private var transfer: TransferViewModel
@StateObject private var widgets = WidgetsViewModel()
@StateObject private var pushManager = PushNotificationManager.shared
Expand Down Expand Up @@ -48,10 +49,16 @@ struct AppScene: View {
_app = StateObject(wrappedValue: AppViewModel(sheetViewModel: sheetViewModel, navigationViewModel: navigationViewModel))
_sheets = StateObject(wrappedValue: sheetViewModel)
_navigation = StateObject(wrappedValue: navigationViewModel)
let walletVm = WalletViewModel(transferService: transferService, sheetViewModel: sheetViewModel)
let feeEstimatesManager = FeeEstimatesManager()
let walletVm = WalletViewModel(
transferService: transferService,
sheetViewModel: sheetViewModel,
feeEstimatesManager: feeEstimatesManager
)
_wallet = StateObject(wrappedValue: walletVm)
_currency = StateObject(wrappedValue: CurrencyViewModel())
_blocktank = StateObject(wrappedValue: BlocktankViewModel())
_feeEstimatesManager = StateObject(wrappedValue: feeEstimatesManager)
_activity = StateObject(wrappedValue: ActivityListViewModel(transferService: transferService))
_transfer = StateObject(wrappedValue: TransferViewModel(
transferService: transferService,
Expand Down Expand Up @@ -112,6 +119,7 @@ struct AppScene: View {
.environmentObject(wallet)
.environmentObject(currency)
.environmentObject(blocktank)
.environmentObject(feeEstimatesManager)
.environmentObject(activity)
.environmentObject(transfer)
.environmentObject(widgets)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "clock-clockwise.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Binary file not shown.
3 changes: 2 additions & 1 deletion Bitkit/Components/Activity/ActivityList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SwiftUI

struct ActivityList: View {
@EnvironmentObject var activity: ActivityListViewModel
@EnvironmentObject var feeEstimatesManager: FeeEstimatesManager
@State private var isHorizontalSwipe = false

let viewType: ActivityViewType
Expand All @@ -27,7 +28,7 @@ struct ActivityList: View {

case let .activity(item):
NavigationLink(value: Route.activityDetail(item)) {
ActivityRow(item: item, feeEstimates: activity.feeEstimates)
ActivityRow(item: item, feeEstimates: feeEstimatesManager.estimates)
}
.accessibilityIdentifier("Activity-\(index)")
.disabled(isHorizontalSwipe)
Expand Down
10 changes: 8 additions & 2 deletions Bitkit/Components/FeeItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@ struct FeeItem: View {
let amount: UInt64
let isSelected: Bool
let isDisabled: Bool
/// When set (e.g. for custom speed with fee estimates), shown instead of `speed.range` as the subtitle.
var rangeOverride: String?
let onPress: () -> Void

@EnvironmentObject var currency: CurrencyViewModel

private var rangeText: String {
rangeOverride ?? speed.range
}

var body: some View {
VStack(spacing: 0) {
Divider()
Expand All @@ -23,8 +29,8 @@ struct FeeItem: View {

HStack {
VStack(alignment: .leading, spacing: 0) {
BodyMSBText(speed.displayTitle, textColor: isDisabled ? .gray3 : .textPrimary)
BodySSBText(speed.displayDescription, textColor: isDisabled ? .gray3 : .textSecondary)
BodyMSBText(speed.title, textColor: isDisabled ? .gray3 : .textPrimary)
BodySSBText(rangeText, textColor: isDisabled ? .gray3 : .textSecondary)
}

Spacer()
Expand Down
10 changes: 8 additions & 2 deletions Bitkit/Components/MoneyText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ enum MoneyUnitType {
struct MoneyText: View {
let sats: Int
var unitType: MoneyUnitType = .primary
/// When set, overrides user preference so the value is always shown in this unit.
var forceUnit: PrimaryDisplay?
/// When set, overrides user preference so the value is always shown in this denomination.
var forceDisplayUnit: BitcoinDisplayUnit?
var size: MoneySize = .display
var symbol: Bool?
var enableHide: Bool = false
Expand All @@ -33,7 +37,8 @@ struct MoneyText: View {
// MARK: - Computed Properties

private var unit: PrimaryDisplay {
unitType == .secondary ? (currency.primaryDisplay == .bitcoin ? .fiat : .bitcoin) : currency.primaryDisplay
if let forceUnit { return forceUnit }
return unitType == .secondary ? (currency.primaryDisplay == .bitcoin ? .fiat : .bitcoin) : currency.primaryDisplay
}

private var showSymbol: Bool {
Expand Down Expand Up @@ -138,7 +143,8 @@ extension MoneyText {
case .fiat:
return converted.formatted
case .bitcoin:
return converted.bitcoinDisplay(unit: currency.displayUnit).value
let displayUnit = forceDisplayUnit ?? currency.displayUnit
return converted.bitcoinDisplay(unit: displayUnit).value
}
}

Expand Down
40 changes: 40 additions & 0 deletions Bitkit/Managers/FeeEstimatesManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import BitkitCore
import Foundation
import SwiftUI

/// Single source of truth for on-chain fee estimates (fast/mid/slow). Fetches and caches
/// When the dev "override fees" setting is on, returns fixed rates for UI work without hitting the backend.
@MainActor
final class FeeEstimatesManager: ObservableObject {
@Published private(set) var estimates: FeeRates?

/// Dev setting: use hardcoded fee rates for UI development. Toggle is in Dev settings (regtest).
@AppStorage("devOverrideFeeEstimates") var devOverrideFeeEstimates = false

private let coreService: CoreService

init(coreService: CoreService = .shared) {
self.coreService = coreService
}

/// Fetches fee rates and updates the cache.
/// - Parameter refresh: If true, forces a fresh fetch; otherwise may use backend cache.
/// - Returns: Current fee rates, or nil if unavailable.
@discardableResult
func getEstimates(refresh: Bool = false) async -> FeeRates? {
if devOverrideFeeEstimates {
let rates = FeeRates(fast: 10, mid: 7, slow: 3)
estimates = rates
return rates
}

do {
let rates = try await coreService.blocktank.fees(refresh: refresh)
estimates = rates
return rates
} catch {
Logger.error("Failed to get fee estimates: \(error)", context: "FeeEstimatesManager")
return nil
}
}
}
119 changes: 37 additions & 82 deletions Bitkit/Models/TransactionSpeed.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,55 +42,33 @@ public enum TransactionSpeed: Equatable, Hashable, RawRepresentable {
// MARK: - Display Properties

public extension TransactionSpeed {
var displayTitle: String {
/// Component used to build fee localization keys (e.g. "fee__fast__title", "fee__fast__longTitle").
var feeKeyComponent: String {
switch self {
case .fast: return t("fee__fast__title")
case .normal: return t("fee__normal__title")
case .slow: return t("fee__slow__title")
case .custom: return t("fee__custom__title")
case .fast: return "fast"
case .normal: return "normal"
case .slow: return "slow"
case .custom: return "custom"
}
}

var displayDescription: String {
switch self {
case .fast: return t("fee__fast__description")
case .normal: return t("fee__normal__description")
case .slow: return t("fee__slow__description")
case .custom: return t("fee__custom__description")
}
var title: String { t("fee__\(feeKeyComponent)__title") }
var longTitle: String { t("fee__\(feeKeyComponent)__longTitle") }
var description: String { t("fee__\(feeKeyComponent)__description") }
var shortDescription: String { t("fee__\(feeKeyComponent)__shortDescription") }
var range: String { t("fee__\(feeKeyComponent)__range") }
var longRange: String { t("fee__\(feeKeyComponent)__longRange") }

var isCustom: Bool {
if case .custom = self { return true }
return false
}

var customSetSpeed: String? {
guard case let .custom(satsPerVByte) = self else { return nil }
return "\(satsPerVByte) \(t("common__sat_vbyte_compact"))"
}
}

// MARK: - Settings Display Properties

public extension TransactionSpeed {
var displayLabel: String {
switch self {
case .fast: return t("settings__fee__fast__label")
case .normal: return t("settings__fee__normal__label")
case .slow: return t("settings__fee__slow__label")
case .custom: return t("settings__fee__custom__label")
}
}

var displayValue: String {
switch self {
case .fast: return t("settings__fee__fast__value")
case .normal: return t("settings__fee__normal__value")
case .slow: return t("settings__fee__slow__value")
case .custom: return t("settings__fee__custom__value")
}
}
}

// MARK: - UI Properties

public extension TransactionSpeed {
var iconName: String {
switch self {
case .fast: return "speed-fast"
Expand All @@ -113,6 +91,16 @@ public extension TransactionSpeed {
// MARK: - Business Logic

public extension TransactionSpeed {
/// Key suffix for fee tier localization (matches "fee__{tier}__{variant}" in Localizable.strings).
enum FeeTierVariant: String {
case title
case longTitle
case description
case shortDescription
case range
case longRange
}

/// Returns the fee rate in satoshis per virtual byte for this speed
/// - Parameter feeRates: Current network fee rates
/// - Returns: Fee rate in sat/vB
Expand All @@ -135,51 +123,18 @@ public extension TransactionSpeed {
return rate >= minRate && rate <= maxRate
}

/// Determines the appropriate fee description for a given fee rate
/// - Parameters:
/// - feeRate: The fee rate in satoshis per virtual byte
/// - feeEstimates: Current network fee estimates
/// - Returns: Localized fee description string
static func getFeeDescription(feeRate: UInt64, feeEstimates: FeeRates) -> String {
// Check against fee estimates in order of priority (highest to lowest)
if feeRate >= UInt64(feeEstimates.fast) {
return t("fee__fast__shortDescription")
} else if feeRate >= UInt64(feeEstimates.mid) {
return t("fee__normal__shortDescription")
} else if feeRate >= UInt64(feeEstimates.slow) {
return t("fee__slow__shortDescription")
} else {
// For rates below slow, use minimum
return t("fee__minimum__shortDescription")
}
/// Tier derived from a fee rate and current estimates (fast/normal/slow/minimum). Use with fee localization keys or getFeeTierLocalized.
static func feeTierKeyComponent(for feeRate: UInt64, feeEstimates: FeeRates?) -> String {
guard let estimates = feeEstimates else { return "normal" }
if feeRate >= UInt64(estimates.fast) { return "fast" }
if feeRate >= UInt64(estimates.mid) { return "normal" }
if feeRate >= UInt64(estimates.slow) { return "slow" }
return "minimum"
}

/// Determines the appropriate fee description for a given fee rate with fallback
/// - Parameters:
/// - feeRate: The fee rate in satoshis per virtual byte
/// - feeEstimates: Current network fee estimates (optional)
/// - Returns: Localized fee description string with fallback
static func getFeeDescription(feeRate: UInt64, feeEstimates: FeeRates?) -> String {
guard let estimates = feeEstimates else {
// Fallback when no fee estimates are available
return t("fee__normal__shortDescription")
}

return getFeeDescription(feeRate: feeRate, feeEstimates: estimates)
}
}

// MARK: - Equatable Implementation

public extension TransactionSpeed {
static func == (lhs: TransactionSpeed, rhs: TransactionSpeed) -> Bool {
switch (lhs, rhs) {
case (.fast, .fast), (.normal, .normal), (.slow, .slow):
return true
case let (.custom(lhsRate), .custom(rhsRate)):
return lhsRate == rhsRate
default:
return false
}
/// Returns the localized string for a fee rate's tier and the given variant (e.g. title, description, shortDescription).
static func getFeeTierLocalized(feeRate: UInt64, feeEstimates: FeeRates?, variant: FeeTierVariant) -> String {
let tier = feeTierKeyComponent(for: feeRate, feeEstimates: feeEstimates)
return t("fee__\(tier)__\(variant.rawValue)")
}
}
25 changes: 11 additions & 14 deletions Bitkit/Resources/Localization/ca.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"cards__lightningReady__title" = "Llest";
"cards__lightningReady__description" = "Conectat!";
"cards__transferPending__title" = "Transferència";
"cards__transferPending__description" = "Llest en ±{duration}";
"cards__transferPending__description" = "Llest en {duration}";
"cards__transferClosingChannel__title" = "Initialisation";
"cards__transferClosingChannel__description" = "Mantenir l\'aplicació oberta";
"cards__pin__title" = "Segur";
Expand Down Expand Up @@ -99,6 +99,14 @@
"fee__custom__description" = "Depèn de la tarifa";
"fee__custom__shortRange" = "Depèn de la tarifa";
"fee__custom__shortDescription" = "Depèn de la tarifa";
"fee__fast__longTitle" = "Ràpid (més car)";
"fee__fast__longRange" = "± 10-20 minuts";
"fee__normal__longTitle" = "Normal";
"fee__normal__longRange" = "± 20-60 minuts";
"fee__slow__longTitle" = "Lent (més barat)";
"fee__slow__longRange" = "± 1-2 hores";
"fee__custom__longTitle" = "Personalitzat";
"fee__custom__longRange" = "Depèn de la tarifa";
"lightning__transfer_intro__title" = "Saldo de\n<accent>despesa</accent>";
"lightning__transfer_intro__text" = "Finança el teu saldo de despesa per gaudir de transaccions instantànies i barates amb amics, família i comerciants.";
"lightning__transfer_intro__button" = "Començar";
Expand Down Expand Up @@ -667,17 +675,6 @@
"settings__adv__electrum_server" = "Servidor Electrum";
"settings__adv__rgs_server" = "Rapid-Gossip-Sync";
"settings__adv__bitcoin_network" = "Xarxa Bitcoin";
"settings__fee__fast__label" = "Ràpid (més car)";
"settings__fee__fast__value" = "Ràpid";
"settings__fee__fast__description" = "± 10-20 minuts";
"settings__fee__normal__label" = "Normal";
"settings__fee__normal__value" = "Normal";
"settings__fee__normal__description" = "± 20-60 minuts";
"settings__fee__slow__label" = "Lent (més barat)";
"settings__fee__slow__value" = "Lent";
"settings__fee__slow__description" = "± 1-2 hores";
"settings__fee__custom__label" = "Personalitzat";
"settings__fee__custom__value" = "Personalitzat";
"settings__addr__no_addrs_with_funds" = "No s\'han trobat adreces amb fons en cercar \"{searchTxt}\"";
"settings__addr__no_addrs_str" = "No s\'han trobat adreces en cercar \"{searchTxt}\"";
"settings__addr__index" = "Index: {index}";
Expand Down Expand Up @@ -894,9 +891,9 @@
"wallet__activity_pending" = "Pendent";
"wallet__activity_failed" = "Fallit";
"wallet__activity_transfer" = "Transferència";
"wallet__activity_transfer_savings_pending" = "Des de despesa (±{duration})";
"wallet__activity_transfer_savings_pending" = "Des de despesa ({duration})";
"wallet__activity_transfer_savings_done" = "Des de despesa";
"wallet__activity_transfer_spending_pending" = "Des d\'estalvis (±{duration})";
"wallet__activity_transfer_spending_pending" = "Des d\'estalvis ({duration})";
"wallet__activity_transfer_spending_done" = "Des d\'estalvis";
"wallet__activity_transfer_to_spending" = "A despesa";
"wallet__activity_transfer_to_savings" = "A estalvis";
Expand Down
Loading