diff --git a/BDKSwiftExampleWallet.xcodeproj/project.pbxproj b/BDKSwiftExampleWallet.xcodeproj/project.pbxproj index 630d2b3c..f471f847 100644 --- a/BDKSwiftExampleWallet.xcodeproj/project.pbxproj +++ b/BDKSwiftExampleWallet.xcodeproj/project.pbxproj @@ -74,6 +74,8 @@ AEAB03132ABDDBF4000C9528 /* AmountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEAB03122ABDDBF4000C9528 /* AmountViewModel.swift */; }; AEAF83B62B7BD4D10019B23B /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = AEAF83B52B7BD4D10019B23B /* CodeScanner */; }; AEB130C92A44E4850087785B /* TransactionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB130C82A44E4850087785B /* TransactionDetailView.swift */; }; + AEB159D32D51A7E00006AE9E /* BalanceDisplayFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB159D22D51A7E00006AE9E /* BalanceDisplayFormat.swift */; }; + AEB159D52D51A8680006AE9E /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB159D42D51A8680006AE9E /* View+Extensions.swift */; }; AEB6C9D12B7E8529003AD704 /* TransactionDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB6C9D02B7E8529003AD704 /* TransactionDetailViewModel.swift */; }; AEB735D32B2CC4B900F99DBB /* BitcoinUI in Frameworks */ = {isa = PBXBuildFile; productRef = AEB735D22B2CC4B900F99DBB /* BitcoinUI */; }; AEB905C32A7EEBF000CD0337 /* BackupInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB905C22A7EEBF000CD0337 /* BackupInfo.swift */; }; @@ -167,6 +169,8 @@ AEAB03102ABDDB86000C9528 /* FeeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeeViewModel.swift; sourceTree = ""; }; AEAB03122ABDDBF4000C9528 /* AmountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountViewModel.swift; sourceTree = ""; }; AEB130C82A44E4850087785B /* TransactionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetailView.swift; sourceTree = ""; }; + AEB159D22D51A7E00006AE9E /* BalanceDisplayFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceDisplayFormat.swift; sourceTree = ""; }; + AEB159D42D51A8680006AE9E /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; AEB6C9D02B7E8529003AD704 /* TransactionDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetailViewModel.swift; sourceTree = ""; }; AEB905C22A7EEBF000CD0337 /* BackupInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupInfo.swift; sourceTree = ""; }; AEC2CF592ABFBA19008065E4 /* BuildTransactionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildTransactionViewModel.swift; sourceTree = ""; }; @@ -241,6 +245,8 @@ AE783A042AB4F51F005F0CBA /* String+Extensions.swift */, AE287E762C0F6D200036A748 /* Array+Extensions.swift */, AE7F67062A744CE200CED561 /* Double+Extensions.swift */, + AEB159D42D51A8680006AE9E /* View+Extensions.swift */, + AE8D001B2D19F1760029C4C9 /* UIScreen+Extensions.swift */, AEE6C74D2ABCB48600442ADD /* BDK+Extensions */, ); path = Extensions; @@ -442,6 +448,7 @@ isa = PBXGroup; children = ( AE7F67082A7451AA00CED561 /* Price.swift */, + AEB159D22D51A7E00006AE9E /* BalanceDisplayFormat.swift */, AE2B8C1E2A96797300815B2F /* RecommendedFees.swift */, AE7F670B2A7451D700CED561 /* CurrencyCode.swift */, AEB905C22A7EEBF000CD0337 /* BackupInfo.swift */, @@ -521,7 +528,6 @@ AE184EFB2BFE52C800374362 /* Amount+Extensions.swift */, AE91CEEC2C0FDB70000AAD20 /* SentAndReceivedValues+Extensions.swift */, AE91CEEE2C0FDBC7000AAD20 /* CanonicalTx+Extensions.swift */, - AE8D001B2D19F1760029C4C9 /* UIScreen+Extensions.swift */, ); path = "BDK+Extensions"; sourceTree = ""; @@ -661,6 +667,7 @@ AE7F670C2A7451D700CED561 /* CurrencyCode.swift in Sources */, AE2ADD762B61EFEB00C2A823 /* HomeViewModel.swift in Sources */, AE783A032AB4ECC2005F0CBA /* AddressView.swift in Sources */, + AEB159D52D51A8680006AE9E /* View+Extensions.swift in Sources */, AE7F67052A7446B600CED561 /* PriceService.swift in Sources */, AEAB03132ABDDBF4000C9528 /* AmountViewModel.swift in Sources */, AE7953902A2D5B4400CCB277 /* BDKSwiftExampleWalletError.swift in Sources */, @@ -668,6 +675,7 @@ AE2381B52C60878E00F6B00C /* LocalOutputItemView.swift in Sources */, AE3646262BEDB01200B04E25 /* FileManager+Extensions.swift in Sources */, AEB6C9D12B7E8529003AD704 /* TransactionDetailViewModel.swift in Sources */, + AEB159D32D51A7E00006AE9E /* BalanceDisplayFormat.swift in Sources */, AE18E9382A9528200019D2A4 /* Bundle+Extensions.swift in Sources */, AE79538E2A2D59F000CCB277 /* Constants.swift in Sources */, AE2F255D2BED0BFB002A9AC6 /* AppError.swift in Sources */, diff --git a/BDKSwiftExampleWallet/Extensions/BDK+Extensions/UIScreen+Extensions.swift b/BDKSwiftExampleWallet/Extensions/UIScreen+Extensions.swift similarity index 100% rename from BDKSwiftExampleWallet/Extensions/BDK+Extensions/UIScreen+Extensions.swift rename to BDKSwiftExampleWallet/Extensions/UIScreen+Extensions.swift diff --git a/BDKSwiftExampleWallet/Extensions/View+Extensions.swift b/BDKSwiftExampleWallet/Extensions/View+Extensions.swift new file mode 100644 index 00000000..78683e2c --- /dev/null +++ b/BDKSwiftExampleWallet/Extensions/View+Extensions.swift @@ -0,0 +1,33 @@ +// +// View+Extensions.swift +// BDKSwiftExampleWallet +// +// Created by Matthew Ramsden on 2/3/25. +// + +import Foundation +import SwiftUI + +extension View { + func swipeGesture(perform action: @escaping (SwipeDirection) -> Void) -> some View { + gesture( + DragGesture(minimumDistance: 20) + .onEnded { value in + let horizontal = value.translation.width + let vertical = value.translation.height + + if abs(horizontal) > abs(vertical) { + if horizontal > 0 { + action(.right) + } else { + action(.left) + } + } + } + ) + } +} + +enum SwipeDirection { + case left, right +} diff --git a/BDKSwiftExampleWallet/Model/BalanceDisplayFormat.swift b/BDKSwiftExampleWallet/Model/BalanceDisplayFormat.swift new file mode 100644 index 00000000..9cbd1370 --- /dev/null +++ b/BDKSwiftExampleWallet/Model/BalanceDisplayFormat.swift @@ -0,0 +1,29 @@ +// +// BalanceDisplayFormat.swift +// BDKSwiftExampleWallet +// +// Created by Matthew Ramsden on 2/3/25. +// + +import Foundation + +enum BalanceDisplayFormat: String, CaseIterable, Codable { + case sats = "sats" + case bitcoinSats = "bitcoinSats" + case bitcoin = "btc" + case fiat = "usd" + + var displayText: String { + switch self { + case .sats, .bitcoinSats: return "sats" + case .bitcoin: return "" + case .fiat: return "USD" + } + } +} + +extension BalanceDisplayFormat { + var index: Int { + BalanceDisplayFormat.allCases.firstIndex(of: self) ?? 0 + } +} diff --git a/BDKSwiftExampleWallet/View Model/WalletViewModel.swift b/BDKSwiftExampleWallet/View Model/WalletViewModel.swift index 9409413b..da99f535 100644 --- a/BDKSwiftExampleWallet/View Model/WalletViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/WalletViewModel.swift @@ -8,6 +8,7 @@ import BitcoinDevKit import Foundation import Observation +import SwiftUI @MainActor @Observable @@ -25,7 +26,8 @@ class WalletViewModel { var price: Double = 0.00 var progress: Float = 0.0 var recentTransactions: [CanonicalTx] { - Array(transactions.prefix(5)) + let maxTransactions = UIScreen.main.isPhoneSE ? 4 : 5 + return Array(transactions.prefix(maxTransactions)) } var satsPrice: Double { let usdValue = Double(balanceTotal).valueInUSD(price: price) diff --git a/BDKSwiftExampleWallet/View/WalletView.swift b/BDKSwiftExampleWallet/View/WalletView.swift index 6ed2be54..0f9af4fb 100644 --- a/BDKSwiftExampleWallet/View/WalletView.swift +++ b/BDKSwiftExampleWallet/View/WalletView.swift @@ -10,13 +10,17 @@ import BitcoinUI import SwiftUI struct WalletView: View { + @AppStorage("balanceDisplayFormat") private var balanceFormat: BalanceDisplayFormat = + .bitcoinSats @Bindable var viewModel: WalletViewModel @Binding var sendNavigationPath: NavigationPath + @State private var balanceTextPulsingOpacity: Double = 0.7 @State private var isFirstAppear = true @State private var newTransactionSent = false @State private var showAllTransactions = false @State private var showReceiveView = false @State private var showSettingsView = false + @State private var showingFormatMenu = false var body: some View { @@ -27,56 +31,41 @@ struct WalletView: View { VStack(spacing: 20) { VStack(spacing: 10) { - withAnimation { - HStack(spacing: 15) { - Image(systemName: "bitcoinsign") - .foregroundStyle(.secondary) - .font(.title) - .fontWeight(.thin) - Text(viewModel.balanceTotal.formattedSatoshis()) - .contentTransition(.numericText()) - .fontWeight(.semibold) - .fontDesign(.rounded) - Text("sats") - .foregroundStyle(.secondary) - .fontWeight(.thin) - } - .font(.largeTitle) - .lineLimit(1) - .minimumScaleFactor(0.5) + HStack(spacing: 15) { + currencySymbol + balanceText + unitText } - .accessibilityLabel("Bitcoin Balance") - .accessibilityValue("\(viewModel.balanceTotal.formattedSatoshis()) sats") - HStack { - if viewModel.walletSyncState == .syncing { - Image(systemName: "chart.bar.fill") - .symbolEffect(.variableColor.cumulative) - .transition(.symbolEffect(.appear)) + .font(.largeTitle) + .lineLimit(1) + .minimumScaleFactor(0.5) + } + .accessibilityLabel("Bitcoin Balance") + .accessibilityValue(formattedBalance) + .onTapGesture { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + balanceFormat = + BalanceDisplayFormat.allCases[ + (balanceFormat.index + 1) % BalanceDisplayFormat.allCases.count + ] + } + } + .swipeGesture { direction in + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + switch direction { + case .left: + balanceFormat = + BalanceDisplayFormat.allCases[ + (balanceFormat.index + 1) % BalanceDisplayFormat.allCases.count + ] + case .right: + balanceFormat = + BalanceDisplayFormat.allCases[ + (balanceFormat.index - 1 + BalanceDisplayFormat.allCases.count) + % BalanceDisplayFormat.allCases.count + ] } - Text( - viewModel.satsPrice > 0 || viewModel.walletSyncState == .synced - ? viewModel.satsPrice.formatted(.currency(code: "USD")) : "" - ) - .fontDesign(.rounded) - .foregroundStyle( - viewModel.walletSyncState == .synced ? .secondary : .tertiary - ) - .opacity( - viewModel.walletSyncState == .syncing && viewModel.satsPrice == 0 - ? 0.7 : 1 - ) - .contentTransition(.numericText()) - .animation( - .spring(response: 0.5, dampingFraction: 0.6, blendDuration: 0.5), - value: viewModel.satsPrice - ) } - .foregroundStyle(.secondary) - .font(.subheadline) - .animation( - .spring(response: 0.5, dampingFraction: 0.6, blendDuration: 0.5), - value: viewModel.walletSyncState - ) } .padding(.vertical, 35.0) @@ -300,6 +289,75 @@ struct WalletView: View { } +extension WalletView { + + @MainActor + var formattedBalance: String { + switch balanceFormat { + case .sats: + return viewModel.balanceTotal.formatted(.number) + case .bitcoinSats: + return viewModel.balanceTotal.formattedSatoshis() + case .bitcoin: + return String(format: "%.8f", Double(viewModel.balanceTotal) / 100_000_000) + case .fiat: + return String(format: "%.2f", viewModel.satsPrice) + } + } + + private var currencySymbol: some View { + Image(systemName: balanceFormat == .fiat ? "dollarsign" : "bitcoinsign") + .foregroundStyle(.secondary) + .font(.title) + .fontWeight(.thin) + .transition( + .asymmetric( + insertion: .move(edge: .leading).combined(with: .opacity), + removal: .move(edge: .trailing).combined(with: .opacity) + ) + ) + .opacity(balanceFormat == .sats ? 0 : 1) + .id("symbol-\(balanceFormat)") + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: balanceFormat) + } + + @MainActor + var balanceText: some View { + Text(balanceFormat == .fiat && viewModel.satsPrice == 0 ? "00.00" : formattedBalance) + .contentTransition(.numericText(countsDown: true)) + .fontWeight(.semibold) + .fontDesign(.rounded) + .foregroundStyle( + balanceFormat == .fiat && viewModel.satsPrice == 0 ? .secondary : .primary + ) + .opacity( + balanceFormat == .fiat && viewModel.satsPrice == 0 ? balanceTextPulsingOpacity : 1 + ) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: balanceFormat) + .animation(.easeInOut, value: viewModel.satsPrice) + .onAppear { + withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) { + balanceTextPulsingOpacity = 0.3 + } + } + } + + private var unitText: some View { + Text(balanceFormat.displayText) + .foregroundStyle(.secondary) + .fontWeight(.thin) + .transition( + .asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + ) + ) + .id("format-\(balanceFormat)") + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: balanceFormat) + } + +} + #if DEBUG #Preview("WalletView - en") { WalletView(