From cb862dd40f793cb5f1bbb63985332b3eabf91f0f Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:37:35 -0500 Subject: [PATCH 1/6] (ios) Including final wallet balance in HomeScreen, and adding UI similar to Android. --- .../phoenix-ios.xcodeproj/project.pbxproj | 4 + phoenix-ios/phoenix-ios/Localizable.xcstrings | 40 +++ .../kotlin/KotlinExtensions+Bitcoin.swift | 11 + .../kotlin/KotlinExtensions+Lightning.swift | 18 +- .../kotlin/KotlinExtensions+Manager.swift | 8 + .../kotlin/KotlinPublishers+Phoenix.swift | 17 + phoenix-ios/phoenix-ios/utils/Utils.swift | 29 +- .../advanced/wallet/SwapInWalletDetails.swift | 2 +- .../phoenix-ios/views/main/HomeView.swift | 85 ++++- .../views/main/IncomingBalancePopover.swift | 328 ++++++++++++++++++ 10 files changed, 492 insertions(+), 50 deletions(-) create mode 100644 phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj index b9314495a..5f144c85b 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj @@ -227,6 +227,7 @@ DC9B8EE225D72CC200E13818 /* ForceCloseChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9B8EE125D72CC200E13818 /* ForceCloseChannelsView.swift */; }; DC9CF83D2D2C6D37003F3B0F /* ScrollView_18.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9CF83C2D2C6D31003F3B0F /* ScrollView_18.swift */; }; DC9CF83F2D2DC3F3003F3B0F /* GeometryGroup_17.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9CF83E2D2DC3ED003F3B0F /* GeometryGroup_17.swift */; }; + DC9CF8412D2ECF08003F3B0F /* IncomingBalancePopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9CF8402D2ECEF7003F3B0F /* IncomingBalancePopover.swift */; }; DC9E7EC32A12955300A5F1D0 /* LiquidityHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9E7EC22A12955300A5F1D0 /* LiquidityHTML.swift */; }; DC9E7EC62A1295B100A5F1D0 /* liquidity.html in Resources */ = {isa = PBXBuildFile; fileRef = DC9E7EC82A1295B100A5F1D0 /* liquidity.html */; }; DCA02B9D2BD065BF0080520F /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = DCA02B9C2BD065BF0080520F /* PrivacyInfo.xcprivacy */; }; @@ -640,6 +641,7 @@ DC9B8EE125D72CC200E13818 /* ForceCloseChannelsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForceCloseChannelsView.swift; sourceTree = ""; }; DC9CF83C2D2C6D31003F3B0F /* ScrollView_18.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollView_18.swift; sourceTree = ""; }; DC9CF83E2D2DC3ED003F3B0F /* GeometryGroup_17.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeometryGroup_17.swift; sourceTree = ""; }; + DC9CF8402D2ECEF7003F3B0F /* IncomingBalancePopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingBalancePopover.swift; sourceTree = ""; }; DC9E7EC22A12955300A5F1D0 /* LiquidityHTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidityHTML.swift; sourceTree = ""; }; DC9E7EC72A1295B100A5F1D0 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = Base; path = Base.lproj/liquidity.html; sourceTree = ""; }; DCA02B9C2BD065BF0080520F /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -1522,6 +1524,7 @@ DCDD9ECF286377B7001800A3 /* MainView_Big.swift */, DC33C5622A7C15D40053D785 /* MainView_BigPrimary.swift */, 53BEFE171C182513A5762686 /* HomeView.swift */, + DC9CF8402D2ECEF7003F3B0F /* IncomingBalancePopover.swift */, DCDD9ED528637FD7001800A3 /* AppStatusButton.swift */, DCDD9ED328637EBB001800A3 /* ToolsButton.swift */, DC4864D929D4E52C00ACD539 /* BgRefreshDisabledPopover.swift */, @@ -2047,6 +2050,7 @@ DCDD9ECE28637474001800A3 /* Orientation.swift in Sources */, DCCFE6B02B64326F002FFF11 /* OSLogHandler.swift in Sources */, DCDD9ECB28637242001800A3 /* MainView.swift in Sources */, + DC9CF8412D2ECF08003F3B0F /* IncomingBalancePopover.swift in Sources */, DCDD9ED2286377C5001800A3 /* MainView_Small.swift in Sources */, C8D7AFF5BC5754DBBEEB2688 /* ElectrumConfigurationView.swift in Sources */, 53BEFBECABE13063AB28A4D6 /* publishers.swift in Sources */, diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index 49aed40dd..e5f1a0fa0 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -7432,6 +7432,9 @@ } } } + }, + "Attention! Some funds will expire soon and won't be eligible for a swap anymore." : { + }, "Authenticate" : { "comment" : "lnurl-auth: login button title", @@ -9663,6 +9666,9 @@ } } } + }, + "Cannot be swapped anymore, after 4 months waiting. These funds must be spent manually." : { + }, "Cannot create commit tx" : { "localizations" : { @@ -12101,6 +12107,9 @@ } } } + }, + "Confirming: " : { + }, "Connected" : { "comment" : "Connection state", @@ -17929,6 +17938,9 @@ } } } + }, + "Expired: " : { + }, "Explore" : { "localizations" : { @@ -19391,6 +19403,9 @@ } } } + }, + "Final wallet: " : { + }, "Fix It" : { "localizations" : { @@ -41199,6 +41214,9 @@ } } } + }, + "These funds come from closed Lightning channels. They must be spent manually." : { + }, "These funds were not swapped in time. Tap to spend." : { "localizations" : { @@ -45523,6 +45541,9 @@ } } } + }, + "Waiting for confirmation first before they can be swapped to Lightning." : { + }, "waiting for confirmations" : { "comment" : "explanation for pending transaction", @@ -45885,6 +45906,9 @@ } } } + }, + "Waiting for swap: " : { + }, "Waiting to start…" : { "localizations" : { @@ -46807,6 +46831,19 @@ } } } + }, + "Will automatically be swapped to Lightning if the fee is **less than %@** (%@) of the amount." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Will automatically be swapped to Lightning if the fee is **less than %1$@** (%2$@) of the amount." + } + } + } + }, + "Will automatically be swapped to Lightning if the fee is **less than %@**." : { + }, "will be sent to:" : { "localizations" : { @@ -46968,6 +47005,9 @@ } } } + }, + "Will remain on-chain because automated channels management is disabled." : { + }, "With configured proportional fee, we recommend a base fee of at least %@." : { "extractionState" : "manual", diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Bitcoin.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Bitcoin.swift index e12e9c97c..f40db72ae 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Bitcoin.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Bitcoin.swift @@ -3,6 +3,17 @@ import PhoenixShared import CryptoKit +extension Bitcoin_kmpSatoshi { + + func toMilliSatoshi() -> Lightning_kmpMilliSatoshi { + return Lightning_kmpMilliSatoshi(sat: self) + } + + func toMsat() -> Int64 { + return self.toLong() * Utils.Millisatoshis_Per_Satoshi + } +} + extension Bitcoin_kmpTxId { func toHex() -> String { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift index 7c4d4408d..c42bdd6a4 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift @@ -82,28 +82,12 @@ extension Lightning_kmpWalletState.WalletWithConfirmations { return Bitcoin_kmpSatoshi(sat: balance) } - /// The `deeplyConfirmed` property contains UTXO's that are also represented in - /// `lockedUntilRefund` & `readyForRefund`. This property is a subset of - /// `deeplyConfirmed` that excludes those 2 categories. - /// - var readyForSwap: [Lightning_kmpWalletState.Utxo] { - let timedOut = Set(self.lockedUntilRefund + self.readyForRefund) - return deeplyConfirmed.filter { - !timedOut.contains($0) - } - } - - var readyForSwapBalance: Bitcoin_kmpSatoshi { - let balance = readyForSwap.map { $0.amount.toLong() }.sum() - return Bitcoin_kmpSatoshi(sat: balance) - } - /// Returns non-nil if any "ready for swap" UTXO's have an expiration date that /// is less than 30 days away. func expirationWarningInDays() -> Int? { let maxConfirmations = swapInParams.maxConfirmations - let remainingConfirmationsList = readyForSwap.map { + let remainingConfirmationsList = deeplyConfirmed.map { maxConfirmations - confirmations(utxo: $0) } diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift index 7f4501be7..0733ea517 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift @@ -30,6 +30,14 @@ extension BalanceManager { return Lightning_kmpWalletState.WalletWithConfirmations.empty() } } + + func pendingChannelsBalanceValue() -> Lightning_kmpMilliSatoshi { + if let value = self.pendingChannelsBalance.value as? Lightning_kmpMilliSatoshi { + return value + } else { + return Lightning_kmpMilliSatoshi(msat: 0) + } + } } extension ConnectionsManager { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift index 3d4639df1..3a39e0698 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift @@ -144,6 +144,7 @@ extension BalanceManager { fileprivate struct _Key { static var balancePublisher = 0 static var swapInWalletPublisher = 0 + static var pendingChanenslBalancePublisher = 0 } func balancePublisher() -> AnyPublisher { @@ -175,6 +176,22 @@ extension BalanceManager { .eraseToAnyPublisher() } } + + func pendingChannelsBalancePublisher() -> AnyPublisher { + + self.getSetAssociatedObject(storageKey: &_Key.pendingChanenslBalancePublisher) { + + // Transforming from Kotlin: + // ``` + // pendingChannelsBalance: StateFlow + // ``` + KotlinCurrentValueSubject( + self.pendingChannelsBalance + ) + .compactMap { $0 } + .eraseToAnyPublisher() + } + } } // MARK: - diff --git a/phoenix-ios/phoenix-ios/utils/Utils.swift b/phoenix-ios/phoenix-ios/utils/Utils.swift index 60874ff30..63ed875af 100644 --- a/phoenix-ios/phoenix-ios/utils/Utils.swift +++ b/phoenix-ios/phoenix-ios/utils/Utils.swift @@ -12,10 +12,10 @@ enum MsatsPolicy { class Utils { - public static let Millisatoshis_Per_Satoshi = 1_000.0 - public static let Millisatoshis_Per_Bit = 100_000.0 - public static let Millisatoshis_Per_Millibitcoin = 100_000_000.0 - public static let Millisatoshis_Per_Bitcoin = 100_000_000_000.0 + public static let Millisatoshis_Per_Satoshi : Int64 = 1_000 + public static let Millisatoshis_Per_Bit : Int64 = 100_000 + public static let Millisatoshis_Per_Millibitcoin : Int64 = 100_000_000 + public static let Millisatoshis_Per_Bitcoin : Int64 = 100_000_000_000 // -------------------------------------------------- // MARK: Conversion @@ -43,7 +43,7 @@ class Utils { /// Converts from satoshi to millisatoshi /// static func toMsat(sat: Int64) -> Int64 { - return sat * Int64(Millisatoshis_Per_Satoshi) + return sat * Millisatoshis_Per_Satoshi } /// Converts to millisatoshi, the preferred unit for performing conversions. @@ -52,10 +52,10 @@ class Utils { var msat: Double switch bitcoinUnit { - case .sat : msat = amount * Millisatoshis_Per_Satoshi - case .bit : msat = amount * Millisatoshis_Per_Bit - case .mbtc : msat = amount * Millisatoshis_Per_Millibitcoin - default/*.bitcoin*/: msat = amount * Millisatoshis_Per_Bitcoin + case .sat : msat = amount * Double(Millisatoshis_Per_Satoshi) + case .bit : msat = amount * Double(Millisatoshis_Per_Bit) + case .mbtc : msat = amount * Double(Millisatoshis_Per_Millibitcoin) + default/*.bitcoin*/: msat = amount * Double(Millisatoshis_Per_Bitcoin) } if let result = Int64(exactly: msat.rounded(.toNearestOrAwayFromZero)) { @@ -80,10 +80,11 @@ class Utils { static func convertBitcoin(msat: Int64, to bitcoinUnit: BitcoinUnit) -> Double { switch bitcoinUnit { - case .sat : return Double(msat) / Millisatoshis_Per_Satoshi - case .bit : return Double(msat) / Millisatoshis_Per_Bit - case .mbtc : return Double(msat) / Millisatoshis_Per_Millibitcoin - default/*.bitcoin*/: return Double(msat) / Millisatoshis_Per_Bitcoin + case .sat : return Double(msat) / Double(Millisatoshis_Per_Satoshi) + case .bit : return Double(msat) / Double(Millisatoshis_Per_Bit) + case .mbtc : return Double(msat) / Double(Millisatoshis_Per_Millibitcoin) + case .btc : return Double(msat) / Double(Millisatoshis_Per_Bitcoin) + @unknown default : fatalError("Unknown BitcoinUnit") } } @@ -94,7 +95,7 @@ class Utils { // // exchangeRate.price => value of 1.0 BTC in fiat - let btc = Double(msat) / Millisatoshis_Per_Bitcoin + let btc = Double(msat) / Double(Millisatoshis_Per_Bitcoin) let fiat = btc * exchangeRate.price return fiat diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift index c9e1a5e9c..e6c47fa31 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift @@ -475,7 +475,7 @@ struct SwapInWalletDetails: View { func confirmedBalance() -> (FormattedAmount, FormattedAmount) { - let sats = swapInWallet.readyForSwapBalance + let sats = swapInWallet.deeplyConfirmedBalance return formattedBalances(sats) } diff --git a/phoenix-ios/phoenix-ios/views/main/HomeView.swift b/phoenix-ios/phoenix-ios/views/main/HomeView.swift index 371d62ac6..a2e44cda1 100644 --- a/phoenix-ios/phoenix-ios/views/main/HomeView.swift +++ b/phoenix-ios/phoenix-ios/views/main/HomeView.swift @@ -42,6 +42,12 @@ struct HomeView : MVIView { let swapInWalletPublisher = Biz.business.balanceManager.swapInWalletPublisher() @State var swapInWallet = Biz.business.balanceManager.swapInWalletValue() + @State var finalWallet = Biz.business.peerManager.finalWalletValue() + let finalWalletPublisher = Biz.business.peerManager.finalWalletPublisher() + + @State var pendingChannelsBalance = Biz.business.balanceManager.pendingChannelsBalanceValue() + let pendingChannelsBalancePublisher = Biz.business.balanceManager.pendingChannelsBalancePublisher() + @State var channels: [LocalChannelInfo] = [] let channelsPublisher = Biz.business.peerManager.channelsPublisher() @@ -129,6 +135,12 @@ struct HomeView : MVIView { .onReceive(swapInWalletPublisher) { swapInWalletChanged($0) } + .onReceive(finalWalletPublisher) { + finalWalletChanged($0) + } + .onReceive(pendingChannelsBalancePublisher) { + pendingChannelsBalanceChanged($0) + } .onReceive(channelsPublisher) { channelsChanged($0) } @@ -286,29 +298,40 @@ struct HomeView : MVIView { @ViewBuilder func incomingBalance() -> some View { - let incomingSat = swapInWallet.totalBalance.sat - if incomingSat > 0 { + let swapInWalletBalance: Int64 = swapInWallet.totalBalance.toMsat() + let finalWalletBalance: Int64 = finalWallet.totalBalance.toMsat() + let pendingBalance: Int64 = pendingChannelsBalance.msat + + let incomingBalance = swapInWalletBalance + finalWalletBalance + pendingBalance + if incomingBalance > 0 { let formattedAmount = currencyPrefs.hideAmounts ? Utils.hiddenAmount(currencyPrefs) - : Utils.format(currencyPrefs, sat: incomingSat) + : Utils.format(currencyPrefs, sat: incomingBalance) - let unconfirmedBalance = swapInWallet.unconfirmedBalance.sat - let weaklyConfirmedBalance = swapInWallet.weaklyConfirmedBalance.sat + let hasNonSwapInBalance = (finalWalletBalance > 0) || (pendingBalance > 0) HStack(alignment: VerticalAlignment.center, spacing: 0) { - if let days = swapInWallet.expirationWarningInDays(), days <= 1 { - Image(systemName: "exclamationmark.triangle") - .foregroundColor(.appNegative) - .padding(.trailing, 2) - } else if unconfirmedBalance == 0 && weaklyConfirmedBalance == 0 { - Image(systemName: "zzz") - .foregroundColor(.appWarn) - .padding(.trailing, 2) - } else { - Image(systemName: "clock") - .padding(.trailing, 2) - } + Group { + if hasNonSwapInBalance { + Image(systemName: "link") + } else { + let unconfirmedBalance = swapInWallet.unconfirmedBalance.sat + let weaklyConfirmedBalance = swapInWallet.weaklyConfirmedBalance.sat + + if let days = swapInWallet.expirationWarningInDays(), days <= 7 { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.appNegative) + } else if unconfirmedBalance == 0 && weaklyConfirmedBalance == 0 { + Image(systemName: "zzz") + .foregroundColor(.appWarn) + + } else { + Image(systemName: "clock") + } + } + } // + .padding(.trailing, 2) if currencyPrefs.hideAmounts { Text("+\(formattedAmount.digits)".lowercased()) // digits => "***" @@ -320,7 +343,13 @@ struct HomeView : MVIView { } .font(.callout) .foregroundColor(.secondary) - .onTapGesture { showSwapInWallet() } + .onTapGesture { + if hasNonSwapInBalance { + showIncomingBalancePopover() + } else { + showSwapInWallet() + } + } .padding(.top, 7) .padding(.bottom, 2) .scaleEffect(incomingSwapScaleFactor, anchor: .top) @@ -813,6 +842,18 @@ struct HomeView : MVIView { } } + func finalWalletChanged(_ newValue: Lightning_kmpWalletState.WalletWithConfirmations) { + log.trace("finalWalletChanged()") + + finalWallet = newValue + } + + func pendingChannelsBalanceChanged(_ newValue: Lightning_kmpMilliSatoshi) { + log.trace("pendingChannelsBalanceChanged()") + + pendingChannelsBalance = newValue + } + func channelsChanged(_ channels: [LocalChannelInfo]) { log.trace("channelsChanged()") @@ -962,6 +1003,14 @@ struct HomeView : MVIView { } } + func showIncomingBalancePopover() { + log.trace("showIncomingBalancePopover()") + + popoverState.display(dismissable: true) { + IncomingBalancePopover() + } + } + func dismissServerMessage(index: Int) { log.trace("dismissServerMessage(index: \(index))") diff --git a/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift b/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift new file mode 100644 index 000000000..94af9dc7a --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift @@ -0,0 +1,328 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "IncomingBalancePopover" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct IncomingBalancePopover: View { + + @State var swapInWallet = Biz.business.balanceManager.swapInWalletValue() + let swapInWalletPublisher = Biz.business.balanceManager.swapInWalletPublisher() + + @State var finalWallet = Biz.business.peerManager.finalWalletValue() + let finalWalletPublisher = Biz.business.peerManager.finalWalletPublisher() + + @State var pendingChannelsBalance = Biz.business.balanceManager.pendingChannelsBalanceValue() + let pendingChannelsBalancePublisher = Biz.business.balanceManager.pendingChannelsBalancePublisher() + + @State var liquidityPolicy: LiquidityPolicy = GroupPrefs.shared.liquidityPolicy + let liquidityPolicyPublisher = GroupPrefs.shared.liquidityPolicyPublisher + + @EnvironmentObject var popoverState: PopoverState + @EnvironmentObject var currencyPrefs: CurrencyPrefs + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 10) { + group_fundsBeingConfirmed() + group_fundsConfirmedNotLocked() + group_fundsConfirmedExpired() + group_finalWalletBalance() + } + .padding(.vertical, 10) + .onReceive(swapInWalletPublisher) { + swapInWalletChanged($0) + } + .onReceive(finalWalletPublisher) { + finalWalletChanged($0) + } + .onReceive(pendingChannelsBalancePublisher) { + pendingChannelsBalanceChanged($0) + } + .onReceive(liquidityPolicyPublisher) { + liquidityPolicyChanged($0) + } + } + + @ViewBuilder + func group_fundsBeingConfirmed() -> some View { + + let fundsBeingConfirmed: Int64 = + swapInWallet.unconfirmedBalance.toMsat() + + swapInWallet.weaklyConfirmedBalance.toMsat() + + pendingChannelsBalance.msat + + if fundsBeingConfirmed > 0 { + VStack(alignment: HorizontalAlignment.leading, spacing: 5) { + + // Title line + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Image(systemName: "clock") + + let formatted = Utils.format(currencyPrefs, msat: fundsBeingConfirmed) + if formatted.currency.type == .bitcoin { + Text("Confirming: ") + Text(verbatim: "+\(formatted.string)") + } else { + Text("Confirming: ") + Text(verbatim: "+≈\(formatted.string)") + } + } // + .font(.headline) + + // Explanation line + Text("Waiting for confirmation first before they can be swapped to Lightning.") + .font(.callout) + .foregroundStyle(.secondary) + + } // + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color(.secondarySystemBackground)) + .cornerRadius(16) + .padding(.horizontal, 10) + } + } + + @ViewBuilder + func group_fundsConfirmedNotLocked() -> some View { + + #if DEBUG + let fundsConfirmedNotLocked: Int64 = swapInWallet.deeplyConfirmedBalance.sat // + 1_000_000 + #else + let fundsConfirmedNotLocked: Int64 = swapInWallet.deeplyConfirmedBalance.sat + #endif + + if fundsConfirmedNotLocked > 0 { + VStack(alignment: HorizontalAlignment.leading, spacing: 5) { + + let expiringSoon = (swapInWallet.expirationWarningInDays() ?? Int.max) <= 7 + + // Title line + HStack(alignment: VerticalAlignment.center, spacing: 4) { + + Group { + if expiringSoon { + Image(systemName: "exclamationmark.triangle").foregroundColor(.appNegative) + } else { + Image(systemName: "zzz").foregroundColor(.appWarn) + } + } + + let formatted = Utils.format(currencyPrefs, sat: fundsConfirmedNotLocked) + if formatted.currency.type == .bitcoin { + Text("Waiting for swap: ") + Text(verbatim: "+\(formatted.string)") + } else { + Text("Waiting for swap: ") + Text(verbatim: "+≈\(formatted.string)") + } + } // + .font(.headline) + + // Explanation line + Group { + if !liquidityPolicy.enabled { + + Text("Will remain on-chain because automated channels management is disabled.") + + } else { + + let (maxFee, isPercentBased) = maxSwapInFeeDetails() + if isPercentBased { + + let percent = basisPointsAsPercent(liquidityPolicy.effectiveMaxFeeBasisPoints) + Text( + """ + Will automatically be swapped to Lightning if the \ + fee is **less than \(percent)** (\(maxFee.string)) of the amount. + """ + ) + + } else { + + Text( + """ + Will automatically be swapped to Lightning if the \ + fee is **less than \(maxFee.string)**. + """ + ) + } + } + } + .font(.callout) + .foregroundStyle(.secondary) + + // Expiration line + if expiringSoon { + Text("Attention! Some funds will expire soon and won't be eligible for a swap anymore.") + .font(.callout) + .foregroundColor(.appNegative.opacity(0.8)) + } + + } // + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color(.secondarySystemBackground)) + .cornerRadius(16) + .padding(.horizontal, 10) + } + } + + @ViewBuilder + func group_fundsConfirmedExpired() -> some View { + + #if DEBUG + let fundsConfirmedExpired = + swapInWallet.lockedUntilRefundBalance.sat + + swapInWallet.readyForRefundBalance.sat // + 1_000_000 + #else + let fundsConfirmedExpired = + swapInWallet.lockedUntilRefundBalance.sat + + swapInWallet.readyForRefundBalance.sat + #endif + + if fundsConfirmedExpired > 0 { + VStack(alignment: HorizontalAlignment.leading, spacing: 5) { + + // Title line + HStack(alignment: VerticalAlignment.center, spacing: 4) { + + Image(systemName: "x.circle") + + let formatted = Utils.format(currencyPrefs, sat: fundsConfirmedExpired) + if formatted.currency.type == .bitcoin { + Text("Expired: ") + Text(verbatim: "+\(formatted.string)") + } else { + Text("Expired: ") + Text(verbatim: "+≈\(formatted.string)") + } + + } // + .font(.headline) + + // Explanation line + Text("Cannot be swapped anymore, after 4 months waiting. These funds must be spent manually.") + .font(.callout) + .foregroundStyle(.secondary) + + } // + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color(.secondarySystemBackground)) + .cornerRadius(16) + .padding(.horizontal, 10) + } + } + + @ViewBuilder + func group_finalWalletBalance() -> some View { + + #if DEBUG + let finalWalletBalance = finalWallet.totalBalance.sat // + 1_000_000 + #else + let finalWalletBalance = finalWallet.totalBalance.sat + #endif + + if finalWalletBalance > 0 { + VStack(alignment: HorizontalAlignment.leading, spacing: 5) { + + // Title line + HStack(alignment: VerticalAlignment.center, spacing: 4) { + + Image(systemName: "link") + + let formatted = Utils.format(currencyPrefs, sat: finalWalletBalance) + if formatted.currency.type == .bitcoin { + Text("Final wallet: ") + Text(verbatim: "+\(formatted.string)") + } else { + Text("Final wallet: ") + Text(verbatim: "+≈\(formatted.string)") + } + + } // + .font(.headline) + + // Explanation line + Text("These funds come from closed Lightning channels. They must be spent manually.") + .font(.callout) + .foregroundStyle(.secondary) + + } // + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color(.secondarySystemBackground)) + .cornerRadius(16) + .padding(.horizontal, 10) + } + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + func maxSwapInFeeDetails() -> (FormattedAmount, Bool) { + + let absoluteMax: Int64 = liquidityPolicy.effectiveMaxFeeSats + + let readyForSwapBalance: Int64 = swapInWallet.deeplyConfirmedBalance.sat + if readyForSwapBalance > 0 { + + let maxPercent: Double = Double(liquidityPolicy.effectiveMaxFeeBasisPoints) / Double(10_000) + let percentMax: Int64 = Int64(Double(readyForSwapBalance) * maxPercent) + + if percentMax < absoluteMax { + + let formatted = Utils.formatBitcoin(currencyPrefs, sat: percentMax) + return (formatted, true) + } + } + + let formatted = Utils.formatBitcoin(currencyPrefs, sat: absoluteMax) + return (formatted, false) + } + + func basisPointsAsPercent(_ basisPoints: Int32) -> String { + + // Example: 30% == 3,000 basis points + // + // 3,000 / 100 => 30.0 => 3000% + // 3,000 / 100 / 100 => 0.3 => 30% + + let percent = Double(basisPoints) / Double(10_000) + + let formatter = NumberFormatter() + formatter.numberStyle = .percent + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 2 + + return formatter.string(from: NSNumber(value: percent)) ?? "?%" + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func swapInWalletChanged(_ newValue: Lightning_kmpWalletState.WalletWithConfirmations) { + log.trace("swapInWalletChanged()") + + swapInWallet = newValue + } + + func finalWalletChanged(_ newValue: Lightning_kmpWalletState.WalletWithConfirmations) { + log.trace("finalWalletChanged()") + + finalWallet = newValue + } + + func pendingChannelsBalanceChanged(_ newValue: Lightning_kmpMilliSatoshi) { + log.trace("pendingChannelsBalanceChanged()") + + pendingChannelsBalance = newValue + } + + func liquidityPolicyChanged(_ newValue: LiquidityPolicy) { + log.trace("liquidityPolicyChanged()") + + liquidityPolicy = newValue + } +} From e8a7822b8d314ee986a68fabb39a002daf553243 Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:52:13 -0500 Subject: [PATCH 2/6] (ios) Adding navigation to IncomingBalancePopover --- .../configuration/ConfigurationView.swift | 1 + .../advanced/wallet/WalletInfoView.swift | 1 + .../payment options/PaymentOptionsView.swift | 1 + .../views/environment/DeepLink.swift | 43 ++++++++++--------- .../phoenix-ios/views/main/HomeView.swift | 15 +++++-- .../views/main/IncomingBalancePopover.swift | 40 +++++++++++++++++ .../phoenix-ios/views/main/MainView_Big.swift | 6 +++ .../views/main/MainView_BigPrimary.swift | 42 +++++++++++++----- .../views/main/MainView_Small.swift | 26 +++++++++-- 9 files changed, 137 insertions(+), 38 deletions(-) diff --git a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift index 75d0685f1..b710c1ab0 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift @@ -592,6 +592,7 @@ struct ConfigurationList: View { case .liquiditySettings : newNavLinkTag = .ChannelManagement ; delay *= 1 case .forceCloseChannels : newNavLinkTag = .ForceCloseChannels ; delay *= 1 case .swapInWallet : newNavLinkTag = .WalletInfo ; delay *= 2 + case .finalWallet : newNavLinkTag = .WalletInfo ; delay *= 2 } if let newNavLinkTag { diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift index fb847ace6..422c6e195 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift @@ -612,6 +612,7 @@ struct WalletInfoView: View { case .liquiditySettings : break case .forceCloseChannels : break case .swapInWallet : newNavLinkTag = NavLinkTag.SwapInWalletDetails + case .finalWallet : newNavLinkTag = NavLinkTag.FinalWalletDetails } if let newNavLinkTag { diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift b/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift index 5ab649f57..00f798234 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift @@ -384,6 +384,7 @@ struct PaymentOptionsList: View { case .liquiditySettings : break case .forceCloseChannels : break case .swapInWallet : break + case .finalWallet : break } if let newNavLinkTag { diff --git a/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift b/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift index a7c307aca..137a61343 100644 --- a/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift +++ b/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift @@ -16,6 +16,7 @@ enum DeepLink: String, Equatable { case liquiditySettings case forceCloseChannels case swapInWallet + case finalWallet } class DeepLinkManager: ObservableObject { @@ -41,26 +42,28 @@ class DeepLinkManager: ObservableObject { } } - // In iOS 16, Apple deprecated NavigationLink(destination:tag:selection:label:). - // - // The sugggested replacement is to use NavigationLink(value:label:) paired - // with navigationDestination(for:destination:). - // However that solution was only half-baked when Apple released it, and it's riddled with bugs: - // https://github.com/ACINQ/phoenix/pull/333 - // - // The deprecated solution still works for now, but has issues. - // One of which is that manual navigation is fragile. - // Manually setting the `navLinkTag` will often highlight the NavigationLink, - // but won't successfully trigger a navigation. - // If you keep trying (by causing a view refresh) it will work properly. - // - // (This is related to manual navigation via a DeepLink) - - for idx in 1...50 { - DispatchQueue.main.asyncAfter(deadline: .now() + (Double(idx) * 0.100)) { - self.iOS16Workaround = UUID() - } - } + if #unavailable(iOS 17.0) { + // In iOS 16, Apple deprecated NavigationLink(destination:tag:selection:label:). + // + // The sugggested replacement is to use NavigationLink(value:label:) paired + // with navigationDestination(for:destination:). + // However that solution was only half-baked when Apple released it, and it's riddled with bugs: + // https://github.com/ACINQ/phoenix/pull/333 + // + // The deprecated solution still works for now, but has issues. + // One of which is that manual navigation is fragile. + // Manually setting the `navLinkTag` will often highlight the NavigationLink, + // but won't successfully trigger a navigation. + // If you keep trying (by causing a view refresh) it will work properly. + // + // (This is related to manual navigation via a DeepLink) + + for idx in 1...50 { + DispatchQueue.main.asyncAfter(deadline: .now() + (Double(idx) * 0.100)) { + self.iOS16Workaround = UUID() + } + } + } } func unbroadcast(_ value: DeepLink) { diff --git a/phoenix-ios/phoenix-ios/views/main/HomeView.swift b/phoenix-ios/phoenix-ios/views/main/HomeView.swift index a2e44cda1..3690a22da 100644 --- a/phoenix-ios/phoenix-ios/views/main/HomeView.swift +++ b/phoenix-ios/phoenix-ios/views/main/HomeView.swift @@ -18,8 +18,9 @@ struct HomeView : MVIView { private let paymentsPageFetcher = Biz.getPaymentsPageFetcher(name: "HomeView") - let showSwapInWallet: () -> Void let showLiquidityAds: () -> Void + let showSwapInWallet: () -> Void + let showFinalWallet: () -> Void @StateObject var mvi = MVIState({ $0.home() }) @@ -90,11 +91,14 @@ struct HomeView : MVIView { // -------------------------------------------------- init( + showLiquidityAds: @escaping () -> Void, showSwapInWallet: @escaping () -> Void, - showLiquidityAds: @escaping () -> Void + showFinalWallet: @escaping () -> Void ) { - self.showSwapInWallet = showSwapInWallet self.showLiquidityAds = showLiquidityAds + self.showSwapInWallet = showSwapInWallet + self.showFinalWallet = showFinalWallet + self.paymentsPagePublisher = paymentsPageFetcher.paymentsPagePublisher() } @@ -1007,7 +1011,10 @@ struct HomeView : MVIView { log.trace("showIncomingBalancePopover()") popoverState.display(dismissable: true) { - IncomingBalancePopover() + IncomingBalancePopover( + showSwapInWallet: showSwapInWallet, + showFinalWallet: showFinalWallet + ) } } diff --git a/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift b/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift index 94af9dc7a..e7ed82bd0 100644 --- a/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift +++ b/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift @@ -10,6 +10,9 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) struct IncomingBalancePopover: View { + let showSwapInWallet: () -> Void + let showFinalWallet: () -> Void + @State var swapInWallet = Biz.business.balanceManager.swapInWalletValue() let swapInWalletPublisher = Biz.business.balanceManager.swapInWalletPublisher() @@ -52,10 +55,17 @@ struct IncomingBalancePopover: View { @ViewBuilder func group_fundsBeingConfirmed() -> some View { + #if DEBUG + let fundsBeingConfirmed: Int64 = + swapInWallet.unconfirmedBalance.toMsat() + + swapInWallet.weaklyConfirmedBalance.toMsat() + + pendingChannelsBalance.msat // + 1_000_000 + #else let fundsBeingConfirmed: Int64 = swapInWallet.unconfirmedBalance.toMsat() + swapInWallet.weaklyConfirmedBalance.toMsat() + pendingChannelsBalance.msat + #endif if fundsBeingConfirmed > 0 { VStack(alignment: HorizontalAlignment.leading, spacing: 5) { @@ -83,6 +93,9 @@ struct IncomingBalancePopover: View { .padding(10) .background(Color(.secondarySystemBackground)) .cornerRadius(16) + .onTapGesture { + didTapSection_swapInWallet() + } .padding(.horizontal, 10) } } @@ -166,6 +179,9 @@ struct IncomingBalancePopover: View { .padding(10) .background(Color(.secondarySystemBackground)) .cornerRadius(16) + .onTapGesture { + didTapSection_swapInWallet() + } .padding(.horizontal, 10) } } @@ -211,6 +227,9 @@ struct IncomingBalancePopover: View { .padding(10) .background(Color(.secondarySystemBackground)) .cornerRadius(16) + .onTapGesture { + didTapSection_swapInWallet() + } .padding(.horizontal, 10) } } @@ -252,6 +271,9 @@ struct IncomingBalancePopover: View { .padding(10) .background(Color(.secondarySystemBackground)) .cornerRadius(16) + .onTapGesture { + didTapSection_finalWallet() + } .padding(.horizontal, 10) } } @@ -325,4 +347,22 @@ struct IncomingBalancePopover: View { liquidityPolicy = newValue } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func didTapSection_swapInWallet() { + log.trace("didTapSection_swapInWallet()") + + popoverState.close() + showSwapInWallet() + } + + func didTapSection_finalWallet() { + log.trace("didTapSection_finalWallet()") + + popoverState.close() + showFinalWallet() + } } diff --git a/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift b/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift index a53d09174..b3f3f28f7 100644 --- a/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift +++ b/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift @@ -621,6 +621,7 @@ struct MainView_Big: View { case .liquiditySettings : showSettings() case .forceCloseChannels : showSettings() case .swapInWallet : showSettings() + case .finalWallet : showSettings() } if #available(iOS 17, *) { @@ -658,6 +659,11 @@ struct MainView_Big: View { navCoordinator_settings.path.removeAll() navCoordinator_settings.path.append(ConfigurationList.NavLinkTag.WalletInfo) navCoordinator_settings.path.append(WalletInfoView.NavLinkTag.SwapInWalletDetails) + + case .finalWallet: + navCoordinator_settings.path.removeAll() + navCoordinator_settings.path.append(ConfigurationList.NavLinkTag.WalletInfo) + navCoordinator_settings.path.append(WalletInfoView.NavLinkTag.FinalWalletDetails) } } } diff --git a/phoenix-ios/phoenix-ios/views/main/MainView_BigPrimary.swift b/phoenix-ios/phoenix-ios/views/main/MainView_BigPrimary.swift index e7df5e8c4..6a953bb0f 100644 --- a/phoenix-ios/phoenix-ios/views/main/MainView_BigPrimary.swift +++ b/phoenix-ios/phoenix-ios/views/main/MainView_BigPrimary.swift @@ -127,8 +127,9 @@ struct MainView_BigPrimary: View { VStack(alignment: HorizontalAlignment.center, spacing: 0) { HomeView( + showLiquidityAds: showLiquidityAds, showSwapInWallet: showSwapInWallet, - showLiquidityAds: showLiquidityAds + showFinalWallet: showFinalWallet ) .padding(.bottom, 15) footer() @@ -314,15 +315,6 @@ struct MainView_BigPrimary: View { } } - func showSwapInWallet() { - log.trace("showSwapInWallet()") - - popoverState.display(dismissable: true) { - SwapInWalletDetails(location: .popover, popTo: popTo) - .frame(maxHeight: 600) - } - } - func showLiquidityAds() { log.trace("showLiquidityAds()") @@ -332,6 +324,36 @@ struct MainView_BigPrimary: View { } } + func showSwapInWallet() { + log.trace("showSwapInWallet()") + + // We used to show this in a popover: + // popoverState.display(dismissable: true) { + // SwapInWalletDetails(location: .popover, popTo: popTo) + // .frame(maxHeight: 600) + // } + + // But on iPad, it's just as clean to jump straight to the configuration setting. + deepLinkManager.broadcast(.swapInWallet) + } + + func showFinalWallet() { + log.trace("showFinalWallet()") + + // It's currently not possible to use a popover here. + // The problem is: + // > FinalWalletDetails > SpendOnChainFunds > showMinerFeeSheet() + // + // So we have a view(SpendOnChainFunds) within a popover, + // that needs to display another popover(MinerFeeSheet). + // So the user never returns to the SpendOnChainFunds view. + // + // This is fixable with a bit of work. + // But on iPad, it's just as clean to jump straight to the configuration setting. + + deepLinkManager.broadcast(.finalWallet) + } + // -------------------------------------------------- // MARK: Navigation // -------------------------------------------------- diff --git a/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift b/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift index aeca3f7fe..8db9f6e6e 100644 --- a/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift +++ b/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift @@ -17,6 +17,7 @@ struct MainView_Small: View { case SendView case CurrencyConverter case SwapInWalletDetails + case FinalWalletDetails case LiquidityAdsView case LoginView(flow: SendManager.ParseResult_Lnurl_Auth) case ValidateView(flow: SendManager.ParseResult) @@ -29,6 +30,7 @@ struct MainView_Small: View { case .SendView : return "SendView" case .CurrencyConverter : return "CurrencyConverter" case .SwapInWalletDetails : return "SwapInWalletDetails" + case .FinalWalletDetails : return "FinalWalletDetails" case .LiquidityAdsView : return "LiquidityAdsView" case .LoginView(_) : return "LoginView" case .ValidateView(_) : return "ValidateView" @@ -172,8 +174,9 @@ struct MainView_Small: View { VStack(alignment: HorizontalAlignment.center, spacing: 0) { header() HomeView( + showLiquidityAds: showLiquidityAds, showSwapInWallet: showSwapInWallet, - showLiquidityAds: showLiquidityAds + showFinalWallet: showFinalWallet ) footer() } @@ -541,6 +544,9 @@ struct MainView_Small: View { case .SwapInWalletDetails: SwapInWalletDetails(location: .embedded, popTo: popTo) + case .FinalWalletDetails: + FinalWalletDetails() + case .LiquidityAdsView: LiquidityAdsView(location: .embedded) @@ -613,16 +619,22 @@ struct MainView_Small: View { } } + func showLiquidityAds() { + log.trace("showLiquidityAds()") + + navigateTo(.LiquidityAdsView) + } + func showSwapInWallet() { log.trace("showSwapInWallet()") navigateTo(.SwapInWalletDetails) } - func showLiquidityAds() { - log.trace("showLiquidityAds()") + func showFinalWallet() { + log.trace("showFinalWallet()") - navigateTo(.LiquidityAdsView) + navigateTo(.FinalWalletDetails) } // -------------------------------------------------- @@ -680,6 +692,11 @@ struct MainView_Small: View { navCoordinator.path.append(NavLinkTag.ConfigurationView) navCoordinator.path.append(ConfigurationList.NavLinkTag.WalletInfo) navCoordinator.path.append(WalletInfoView.NavLinkTag.SwapInWalletDetails) + + case .finalWallet: + navCoordinator.path.append(NavLinkTag.ConfigurationView) + navCoordinator.path.append(ConfigurationList.NavLinkTag.WalletInfo) + navCoordinator.path.append(WalletInfoView.NavLinkTag.FinalWalletDetails) } } @@ -698,6 +715,7 @@ struct MainView_Small: View { case .liquiditySettings : newNavLinkTag = .ConfigurationView ; delay *= 3 case .forceCloseChannels : newNavLinkTag = .ConfigurationView ; delay *= 2 case .swapInWallet : newNavLinkTag = .ConfigurationView ; delay *= 2 + case .finalWallet : newNavLinkTag = .ConfigurationView ; delay *= 2 } if let newNavLinkTag = newNavLinkTag { From cd6580a60049734e63f54df32826549112f318ff Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:53:12 -0500 Subject: [PATCH 3/6] (ios) Display a badge in the home screen that counts in-flight payments. This mirrors the Android changes in #522 --- .../views/main/AppStatusButton.swift | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/phoenix-ios/phoenix-ios/views/main/AppStatusButton.swift b/phoenix-ios/phoenix-ios/views/main/AppStatusButton.swift index fd60eb098..0851fc1d7 100644 --- a/phoenix-ios/phoenix-ios/views/main/AppStatusButton.swift +++ b/phoenix-ios/phoenix-ios/views/main/AppStatusButton.swift @@ -25,6 +25,9 @@ struct AppStatusButton: View { @StateObject var connectionsMonitor = ObservableConnectionsMonitor() + @State var channels = Biz.business.peerManager.channelsValue() + let channelsPublisher = Biz.business.peerManager.channelsPublisher() + @EnvironmentObject var popoverState: PopoverState @EnvironmentObject var deviceInfo: DeviceInfo @@ -68,6 +71,9 @@ struct AppStatusButton: View { .onReceive(Biz.business.appConfigurationManager.electrumConfigPublisher()) { electrumConfigChanged($0) } + .onReceive(channelsPublisher) { + channelsChanged($0) + } } @ViewBuilder @@ -106,7 +112,7 @@ struct AppStatusButton: View { } else if connectionStatus.isClosed() { HStack(alignment: .firstTextBaseline, spacing: 0) { if showText { - Text(NSLocalizedString("Offline", comment: "Connection state")) + Text("Offline", comment: "Connection state") .font(.caption2) .padding(.leading, 10) .padding(.trailing, -5) @@ -119,7 +125,7 @@ struct AppStatusButton: View { else if connectionStatus.isEstablishing() { HStack(alignment: .firstTextBaseline, spacing: 0) { if showText { - Text(NSLocalizedString("Connecting…", comment: "Connection state")) + Text("Connecting…", comment: "Connection state") .font(.caption2) .padding(.leading, 10) .padding(.trailing, -5) @@ -130,13 +136,26 @@ struct AppStatusButton: View { } } else /* .established */ { - if pendingSettings != nil { + let inFlightPaymentsCount = channels.inFlightPaymentsCount() + if inFlightPaymentsCount > 0 { + HStack(alignment: .firstTextBaseline, spacing: 0) { + Text(verbatim: "\(inFlightPaymentsCount)") + .font(.footnote) + .padding(.leading, 10) + .padding(.trailing, -5) + AppStatusButtonIcon.paymentsInFlight.view() + .frame(minHeight: headerButtonHeight) + .squareFrame() + } + + } else if pendingSettings != nil { // The user enabled/disabled cloud sync. // We are using a 30 second delay before we start operating on the user's decision. - // Just in-case it was an accidental change, or the user changes his/her mind. + // This is a safety measure, in case it was an accidental change, or the user changes their mind. AppStatusButtonIcon.waiting.view() .frame(minHeight: headerButtonHeight) .squareFrame() + } else { let (isSyncing, isWaiting, isError) = buttonizeSyncStatus() if isSyncing { @@ -241,6 +260,12 @@ struct AppStatusButton: View { electrumConfig = newValue } + func channelsChanged(_ newChannels: [LocalChannelInfo]) { + log.trace("channelsChanged()") + + channels = newChannels + } + // -------------------------------------------------- // MARK: User Actions // -------------------------------------------------- @@ -308,6 +333,7 @@ fileprivate enum AppStatusButtonIcon: CaseIterable, Identifiable { case connecting case connected case connectedWithTor + case paymentsInFlight case syncing case waiting case error @@ -337,6 +363,11 @@ fileprivate enum AppStatusButtonIcon: CaseIterable, Identifiable { .imageScale(.large) .font(.subheadline) // bigger .padding(.all, 0) // bigger + case .paymentsInFlight: + Image(systemName: "paperplane") + .imageScale(.large) + .font(.caption2) + .padding(.all, 7) case .syncing: Image(systemName: "icloud") .imageScale(.large) From 5e6b4f10b0b5de63b7fe5494143f73e48c0294d1 Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:53:32 -0500 Subject: [PATCH 4/6] (ios) Fixing compiler issues post-rebase --- .../kotlin/KotlinExtensions+Manager.swift | 8 -------- .../kotlin/KotlinPublishers+Phoenix.swift | 17 ----------------- .../phoenix-ios/views/main/HomeView.swift | 17 ++--------------- .../views/main/IncomingBalancePopover.swift | 18 ++---------------- 4 files changed, 4 insertions(+), 56 deletions(-) diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift index 0733ea517..7f4501be7 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift @@ -30,14 +30,6 @@ extension BalanceManager { return Lightning_kmpWalletState.WalletWithConfirmations.empty() } } - - func pendingChannelsBalanceValue() -> Lightning_kmpMilliSatoshi { - if let value = self.pendingChannelsBalance.value as? Lightning_kmpMilliSatoshi { - return value - } else { - return Lightning_kmpMilliSatoshi(msat: 0) - } - } } extension ConnectionsManager { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift index 3a39e0698..3d4639df1 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift @@ -144,7 +144,6 @@ extension BalanceManager { fileprivate struct _Key { static var balancePublisher = 0 static var swapInWalletPublisher = 0 - static var pendingChanenslBalancePublisher = 0 } func balancePublisher() -> AnyPublisher { @@ -176,22 +175,6 @@ extension BalanceManager { .eraseToAnyPublisher() } } - - func pendingChannelsBalancePublisher() -> AnyPublisher { - - self.getSetAssociatedObject(storageKey: &_Key.pendingChanenslBalancePublisher) { - - // Transforming from Kotlin: - // ``` - // pendingChannelsBalance: StateFlow - // ``` - KotlinCurrentValueSubject( - self.pendingChannelsBalance - ) - .compactMap { $0 } - .eraseToAnyPublisher() - } - } } // MARK: - diff --git a/phoenix-ios/phoenix-ios/views/main/HomeView.swift b/phoenix-ios/phoenix-ios/views/main/HomeView.swift index 3690a22da..08bce1dd6 100644 --- a/phoenix-ios/phoenix-ios/views/main/HomeView.swift +++ b/phoenix-ios/phoenix-ios/views/main/HomeView.swift @@ -46,9 +46,6 @@ struct HomeView : MVIView { @State var finalWallet = Biz.business.peerManager.finalWalletValue() let finalWalletPublisher = Biz.business.peerManager.finalWalletPublisher() - @State var pendingChannelsBalance = Biz.business.balanceManager.pendingChannelsBalanceValue() - let pendingChannelsBalancePublisher = Biz.business.balanceManager.pendingChannelsBalancePublisher() - @State var channels: [LocalChannelInfo] = [] let channelsPublisher = Biz.business.peerManager.channelsPublisher() @@ -142,9 +139,6 @@ struct HomeView : MVIView { .onReceive(finalWalletPublisher) { finalWalletChanged($0) } - .onReceive(pendingChannelsBalancePublisher) { - pendingChannelsBalanceChanged($0) - } .onReceive(channelsPublisher) { channelsChanged($0) } @@ -304,15 +298,14 @@ struct HomeView : MVIView { let swapInWalletBalance: Int64 = swapInWallet.totalBalance.toMsat() let finalWalletBalance: Int64 = finalWallet.totalBalance.toMsat() - let pendingBalance: Int64 = pendingChannelsBalance.msat - let incomingBalance = swapInWalletBalance + finalWalletBalance + pendingBalance + let incomingBalance = swapInWalletBalance + finalWalletBalance if incomingBalance > 0 { let formattedAmount = currencyPrefs.hideAmounts ? Utils.hiddenAmount(currencyPrefs) : Utils.format(currencyPrefs, sat: incomingBalance) - let hasNonSwapInBalance = (finalWalletBalance > 0) || (pendingBalance > 0) + let hasNonSwapInBalance = (finalWalletBalance > 0) HStack(alignment: VerticalAlignment.center, spacing: 0) { @@ -852,12 +845,6 @@ struct HomeView : MVIView { finalWallet = newValue } - func pendingChannelsBalanceChanged(_ newValue: Lightning_kmpMilliSatoshi) { - log.trace("pendingChannelsBalanceChanged()") - - pendingChannelsBalance = newValue - } - func channelsChanged(_ channels: [LocalChannelInfo]) { log.trace("channelsChanged()") diff --git a/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift b/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift index e7ed82bd0..812d7c6fe 100644 --- a/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift +++ b/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift @@ -19,9 +19,6 @@ struct IncomingBalancePopover: View { @State var finalWallet = Biz.business.peerManager.finalWalletValue() let finalWalletPublisher = Biz.business.peerManager.finalWalletPublisher() - @State var pendingChannelsBalance = Biz.business.balanceManager.pendingChannelsBalanceValue() - let pendingChannelsBalancePublisher = Biz.business.balanceManager.pendingChannelsBalancePublisher() - @State var liquidityPolicy: LiquidityPolicy = GroupPrefs.shared.liquidityPolicy let liquidityPolicyPublisher = GroupPrefs.shared.liquidityPolicyPublisher @@ -44,9 +41,6 @@ struct IncomingBalancePopover: View { .onReceive(finalWalletPublisher) { finalWalletChanged($0) } - .onReceive(pendingChannelsBalancePublisher) { - pendingChannelsBalanceChanged($0) - } .onReceive(liquidityPolicyPublisher) { liquidityPolicyChanged($0) } @@ -58,13 +52,11 @@ struct IncomingBalancePopover: View { #if DEBUG let fundsBeingConfirmed: Int64 = swapInWallet.unconfirmedBalance.toMsat() + - swapInWallet.weaklyConfirmedBalance.toMsat() + - pendingChannelsBalance.msat // + 1_000_000 + swapInWallet.weaklyConfirmedBalance.toMsat() // + 1_000_000 #else let fundsBeingConfirmed: Int64 = swapInWallet.unconfirmedBalance.toMsat() + - swapInWallet.weaklyConfirmedBalance.toMsat() + - pendingChannelsBalance.msat + swapInWallet.weaklyConfirmedBalance.toMsat() #endif if fundsBeingConfirmed > 0 { @@ -336,12 +328,6 @@ struct IncomingBalancePopover: View { finalWallet = newValue } - func pendingChannelsBalanceChanged(_ newValue: Lightning_kmpMilliSatoshi) { - log.trace("pendingChannelsBalanceChanged()") - - pendingChannelsBalance = newValue - } - func liquidityPolicyChanged(_ newValue: LiquidityPolicy) { log.trace("liquidityPolicyChanged()") From 128bce28d96fd3bb2a8db64ea8a1954f2b1bb6da Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:14:49 -0500 Subject: [PATCH 5/6] (ios) Updating colors of popover --- .../views/main/IncomingBalancePopover.swift | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift b/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift index 812d7c6fe..2fbbf2783 100644 --- a/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift +++ b/phoenix-ios/phoenix-ios/views/main/IncomingBalancePopover.swift @@ -35,6 +35,7 @@ struct IncomingBalancePopover: View { group_finalWalletBalance() } .padding(.vertical, 10) + .background(Color.primaryBackground) .onReceive(swapInWalletPublisher) { swapInWalletChanged($0) } @@ -49,10 +50,10 @@ struct IncomingBalancePopover: View { @ViewBuilder func group_fundsBeingConfirmed() -> some View { - #if DEBUG + #if DEBUG && false let fundsBeingConfirmed: Int64 = swapInWallet.unconfirmedBalance.toMsat() + - swapInWallet.weaklyConfirmedBalance.toMsat() // + 1_000_000 + swapInWallet.weaklyConfirmedBalance.toMsat() + 1_000_000 #else let fundsBeingConfirmed: Int64 = swapInWallet.unconfirmedBalance.toMsat() + @@ -83,7 +84,7 @@ struct IncomingBalancePopover: View { } // .frame(maxWidth: .infinity, alignment: .leading) .padding(10) - .background(Color(.secondarySystemBackground)) + .background(Color(UIColor.secondarySystemGroupedBackground)) .cornerRadius(16) .onTapGesture { didTapSection_swapInWallet() @@ -95,8 +96,8 @@ struct IncomingBalancePopover: View { @ViewBuilder func group_fundsConfirmedNotLocked() -> some View { - #if DEBUG - let fundsConfirmedNotLocked: Int64 = swapInWallet.deeplyConfirmedBalance.sat // + 1_000_000 + #if DEBUG && false + let fundsConfirmedNotLocked: Int64 = swapInWallet.deeplyConfirmedBalance.sat + 1_000_000 #else let fundsConfirmedNotLocked: Int64 = swapInWallet.deeplyConfirmedBalance.sat #endif @@ -169,7 +170,7 @@ struct IncomingBalancePopover: View { } // .frame(maxWidth: .infinity, alignment: .leading) .padding(10) - .background(Color(.secondarySystemBackground)) + .background(Color(UIColor.secondarySystemGroupedBackground)) .cornerRadius(16) .onTapGesture { didTapSection_swapInWallet() @@ -181,10 +182,10 @@ struct IncomingBalancePopover: View { @ViewBuilder func group_fundsConfirmedExpired() -> some View { - #if DEBUG + #if DEBUG && false let fundsConfirmedExpired = swapInWallet.lockedUntilRefundBalance.sat + - swapInWallet.readyForRefundBalance.sat // + 1_000_000 + swapInWallet.readyForRefundBalance.sat + 1_000_000 #else let fundsConfirmedExpired = swapInWallet.lockedUntilRefundBalance.sat + @@ -217,7 +218,7 @@ struct IncomingBalancePopover: View { } // .frame(maxWidth: .infinity, alignment: .leading) .padding(10) - .background(Color(.secondarySystemBackground)) + .background(Color(UIColor.secondarySystemGroupedBackground)) .cornerRadius(16) .onTapGesture { didTapSection_swapInWallet() @@ -229,8 +230,8 @@ struct IncomingBalancePopover: View { @ViewBuilder func group_finalWalletBalance() -> some View { - #if DEBUG - let finalWalletBalance = finalWallet.totalBalance.sat // + 1_000_000 + #if DEBUG && false + let finalWalletBalance = finalWallet.totalBalance.sat + 1_000_000 #else let finalWalletBalance = finalWallet.totalBalance.sat #endif @@ -261,7 +262,7 @@ struct IncomingBalancePopover: View { } // .frame(maxWidth: .infinity, alignment: .leading) .padding(10) - .background(Color(.secondarySystemBackground)) + .background(Color(UIColor.secondarySystemGroupedBackground)) .cornerRadius(16) .onTapGesture { didTapSection_finalWallet() From 5a676521e86b94f5b2d44960c55e112e4a1886df Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:51:25 -0500 Subject: [PATCH 6/6] (ios) Home screen incoming balance didn't include `swapInWallet.lockedUntilRefund` or `swapInWallet.readyForRefund` --- .../phoenix-ios/kotlin/KotlinExtensions+Lightning.swift | 4 ++-- .../configuration/advanced/wallet/SwapInWalletDetails.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift index c42bdd6a4..c01ee6c6e 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift @@ -77,8 +77,8 @@ extension Lightning_kmpWalletState.WalletWithConfirmations { } var totalBalance: Bitcoin_kmpSatoshi { - let allTx = unconfirmed + weaklyConfirmed + deeplyConfirmed - let balance = allTx.map { $0.amount.toLong() }.sum() + // all: unconfirmed + weaklyConfirmed + deeplyConfirmed + lockedUntilRefund + readyForRefund + let balance = all.map { $0.amount.toLong() }.sum() return Bitcoin_kmpSatoshi(sat: balance) } diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift index e6c47fa31..33695a92d 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift @@ -409,7 +409,7 @@ struct SwapInWalletDetails: View { let absoluteMax: Int64 = liquidityPolicy.effectiveMaxFeeSats - let swapInBalance: Int64 = swapInWallet.totalBalance.sat + let swapInBalance: Int64 = swapInWallet.unconfirmedBalance.sat + swapInWallet.anyConfirmedBalance.sat if swapInBalance > 0 { let maxPercent: Double = Double(liquidityPolicy.effectiveMaxFeeBasisPoints) / Double(10_000)