From 20600ce04d9defd6047cf2df6fc0b99edab3f072 Mon Sep 17 00:00:00 2001 From: kcw-grunt Date: Sun, 13 Sep 2026 18:42:56 +0100 Subject: [PATCH 1/5] Redesign Settings blockchain row layout and wire up live network fees Layout: - Refactor SettingsView's expanding rows (Blockchain, Currency, Security, Theme/Lock actions) to a consistent structure and normalize row heights via shared closedRowHeight/expandArrowSize/leadRowPad/trailRowPad constants in SettingsHelpers. - Rebuild SettingsLitecoinDetailView's peer-sync, blockchain-sync, and network-fee sections with clearer grouping, spacing, and typography (BWIPSSemiBold/BWIPSRegular, BrainwalletColor.content), and scaffold the trusted-node toggle and "Set IP Address" action. - Size the network-fee wheel picker to 40% of the row width via GeometryReader, top-align it with its "Choose preferred fee" label, and always show the blockchain detail ScrollView's scroll indicators. Network fees: - Add NewMainViewModel.currentFees, fetched via BWAPIClient.feePerKb(_:) (GET v1/fee-per-kb), and thread it through SettingsExpandingBlockchainView into SettingsLitecoinDetailView so the fee picker shows real economy/regular/luxury tiers instead of placeholder values. - Compute the fiat cost of the selected fee tier by converting litoshis to LTC and multiplying by the current exchange rate, and persist the user's choice via UserDefaults.userSetPreferredNetworkFee. - Extract the tier math (litoshis-per-tier, nearest-tier lookup for a restored preference, litoshi-to-fiat formatting) into a standalone NetworkFeeTier enum in FeeManager.swift so it's unit-testable without instantiating SwiftUI view state, and add NetworkFeeTierTests covering it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FUKEfvG7cDWbPc1nBhwNgU --- .../Settings Tests/NetworkFeeTierTests.swift | 98 +++++++ Modules/core | 2 +- brainwallet.xcodeproj/project.pbxproj | 4 - .../Additions/UserDefaults+Additions.swift | 15 +- brainwallet/FeeManager.swift | 35 +++ .../Localizations/Localizable.xcstrings | 36 +++ .../New Main Classes/NewMainViewModel.swift | 19 ++ .../Settings Classes/CurrencyPickerView.swift | 4 +- .../SettingsActionLockView.swift | 57 ++-- .../SettingsActionThemeView.swift | 59 ++-- .../SettingsExpandingBlockchainView.swift | 102 ++++--- .../SettingsExpandingCurrencyView.swift | 93 +++--- .../SettingsExpandingSecurityView.swift | 83 +++--- .../Settings Classes/SettingsFooterView.swift | 45 --- .../Settings Classes/SettingsHelpers.swift | 4 +- .../Settings Classes/SettingsLabelView.swift | 39 ++- .../SettingsLitecoinDetailView.swift | 264 ++++++++++++------ .../Settings Classes/SettingsView.swift | 147 +++++----- 18 files changed, 629 insertions(+), 477 deletions(-) create mode 100644 BrainwalletUnitTests/Settings Tests/NetworkFeeTierTests.swift delete mode 100644 brainwallet/Settings Classes/SettingsFooterView.swift diff --git a/BrainwalletUnitTests/Settings Tests/NetworkFeeTierTests.swift b/BrainwalletUnitTests/Settings Tests/NetworkFeeTierTests.swift new file mode 100644 index 00000000..44ae8a83 --- /dev/null +++ b/BrainwalletUnitTests/Settings Tests/NetworkFeeTierTests.swift @@ -0,0 +1,98 @@ +// +// NetworkFeeTierTests.swift +// brainwallet +// +// Covers the fee-tier math backing the Network Fee picker in +// SettingsLitecoinDetailView (litoshis-per-tier, nearest-tier lookup for a +// persisted preference, and litoshi -> fiat formatting). +// +// Copyright © 2025 Grunt Software, LTD. All rights reserved. + +@testable import brainwallet +import XCTest + +class NetworkFeeTierTests: XCTestCase { + + private let fees = Fees(luxury: 66746, regular: 25000, economy: 8000, timestamp: 0) + private let usdRate = Rate(code: "USD", name: "US Dollar", rate: 60.0, lastTimestamp: Date()) + + // MARK: - litoshis(from:) + + func testLitoshisFromMapsEachTierToItsFeesField() { + XCTAssertEqual(NetworkFeeTier.economy.litoshis(from: fees), fees.economy) + XCTAssertEqual(NetworkFeeTier.regular.litoshis(from: fees), fees.regular) + XCTAssertEqual(NetworkFeeTier.luxury.litoshis(from: fees), fees.luxury) + } + + // MARK: - closest(to:in:) + + func testClosestReturnsExactTierOnExactMatch() { + XCTAssertEqual(NetworkFeeTier.closest(to: Int(fees.economy), in: fees), .economy) + XCTAssertEqual(NetworkFeeTier.closest(to: Int(fees.regular), in: fees), .regular) + XCTAssertEqual(NetworkFeeTier.closest(to: Int(fees.luxury), in: fees), .luxury) + } + + func testClosestPicksNearerNeighborBetweenTwoTiers() { + // Between economy (8000) and regular (25000); nearer to economy. + XCTAssertEqual(NetworkFeeTier.closest(to: 10_000, in: fees), .economy) + // Between regular (25000) and luxury (66746); nearer to luxury. + XCTAssertEqual(NetworkFeeTier.closest(to: 60_000, in: fees), .luxury) + } + + func testClosestClampsToEconomyBelowLowestTier() { + XCTAssertEqual(NetworkFeeTier.closest(to: 0, in: fees), .economy) + } + + func testClosestClampsToLuxuryAboveHighestTier() { + XCTAssertEqual(NetworkFeeTier.closest(to: 1_000_000, in: fees), .luxury) + } + + func testClosestBreaksTiesTowardTheLowerTier() { + // Exactly halfway between economy (8000) and regular (25000) is 16500; + // a tie should favor the lower (economy) tier. + let tieFees = Fees(luxury: 30000, regular: 20000, economy: 10000, timestamp: 0) + XCTAssertEqual(NetworkFeeTier.closest(to: 15000, in: tieFees), .economy) + // Halfway between regular (20000) and luxury (30000) is 25000. + XCTAssertEqual(NetworkFeeTier.closest(to: 25000, in: tieFees), .regular) + } + + func testClosestFallsBackToDefaultValuesWhenNothingIsStored() { + // UserDefaults.userSetPreferredNetworkFee falls back to Fees.usingDefaultValues.luxury + // when the user has never picked a fee — confirm that round-trips to .luxury. + let defaults = Fees.usingDefaultValues + let storedFee = Int(defaults.luxury) + XCTAssertEqual(NetworkFeeTier.closest(to: storedFee, in: defaults), .luxury) + } + + // MARK: - formattedFiatAmount(litoshis:rate:) + + func testFormattedFiatAmountConvertsLitoshisThroughLTCToFiat() { + // 100_000_000 litoshis == 1 LTC == $60 at a $60/LTC rate. + let result = NetworkFeeTier.formattedFiatAmount(litoshis: 100_000_000, rate: usdRate) + XCTAssertEqual(result, "60.000 USD") + } + + func testFormattedFiatAmountForRealisticRegularFee() { + // 25000 litoshis at $60/LTC = 0.00025 LTC * 60 = 0.015. + let result = NetworkFeeTier.formattedFiatAmount(litoshis: fees.regular, rate: usdRate) + XCTAssertEqual(result, "0.015 USD") + } + + func testFormattedFiatAmountIsEmptyWhenFeeIsZero() { + XCTAssertEqual(NetworkFeeTier.formattedFiatAmount(litoshis: 0, rate: usdRate), "") + } + + func testFormattedFiatAmountIsEmptyWhenRateIsZero() { + let noRate = Rate(code: "", name: "", rate: 0.0, lastTimestamp: Date()) + XCTAssertEqual(NetworkFeeTier.formattedFiatAmount(litoshis: fees.luxury, rate: noRate), "") + } + + // MARK: - CaseIterable ordering (Picker tags rely on rawValue == 0/1/2) + + func testAllCasesOrderMatchesPickerTagOrder() { + XCTAssertEqual(NetworkFeeTier.allCases.map(\.rawValue), [0, 1, 2]) + XCTAssertEqual(NetworkFeeTier.economy.rawValue, 0) + XCTAssertEqual(NetworkFeeTier.regular.rawValue, 1) + XCTAssertEqual(NetworkFeeTier.luxury.rawValue, 2) + } +} diff --git a/Modules/core b/Modules/core index 36f38224..1afc45e0 160000 --- a/Modules/core +++ b/Modules/core @@ -1 +1 @@ -Subproject commit 36f38224b1ecca091a4c118b618254ab1575bd32 +Subproject commit 1afc45e0822eeb8d4c3a4b3c756bd2add75edf74 diff --git a/brainwallet.xcodeproj/project.pbxproj b/brainwallet.xcodeproj/project.pbxproj index 0f81e682..47d80222 100644 --- a/brainwallet.xcodeproj/project.pbxproj +++ b/brainwallet.xcodeproj/project.pbxproj @@ -216,7 +216,6 @@ 24F378022DE89C7800A388AB /* MoonPayHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F378012DE89C6600A388AB /* MoonPayHelper.swift */; }; 24F5BD442E042F1D0010D340 /* clicksound.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 24F5BD432E042F1D0010D340 /* clicksound.mp3 */; }; 24F5BD482E043C2A0010D340 /* SettingsLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F5BD472E043C1B0010D340 /* SettingsLabelView.swift */; }; - 24F5BD4A2E0442D00010D340 /* SettingsFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F5BD492E0442D00010D340 /* SettingsFooterView.swift */; }; 24F5BD4C2E0450B80010D340 /* SettingsExpandingSecurityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F5BD4B2E0450B80010D340 /* SettingsExpandingSecurityView.swift */; }; 24F5BD4E2E0452670010D340 /* SettingsActionLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F5BD4D2E0452670010D340 /* SettingsActionLockView.swift */; }; 24F5BD502E0460C90010D340 /* SecurityListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F5BD4F2E0460C90010D340 /* SecurityListView.swift */; }; @@ -793,7 +792,6 @@ 24F378012DE89C6600A388AB /* MoonPayHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoonPayHelper.swift; sourceTree = ""; }; 24F5BD432E042F1D0010D340 /* clicksound.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = clicksound.mp3; sourceTree = ""; }; 24F5BD472E043C1B0010D340 /* SettingsLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLabelView.swift; sourceTree = ""; }; - 24F5BD492E0442D00010D340 /* SettingsFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFooterView.swift; sourceTree = ""; }; 24F5BD4B2E0450B80010D340 /* SettingsExpandingSecurityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsExpandingSecurityView.swift; sourceTree = ""; }; 24F5BD4D2E0452670010D340 /* SettingsActionLockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsActionLockView.swift; sourceTree = ""; }; 24F5BD4F2E0460C90010D340 /* SecurityListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityListView.swift; sourceTree = ""; }; @@ -1725,7 +1723,6 @@ 24DB05EB2E06B5BB00C57FE9 /* SettingsExpandingCurrencyView.swift */, 24DB05E92E06B49B00C57FE9 /* SettingsExpandingGamesView.swift */, 24DB05E72E06B44700C57FE9 /* SettingsExpandingBlockchainView.swift */, - 24F5BD492E0442D00010D340 /* SettingsFooterView.swift */, ); path = "Settings Classes"; sourceTree = ""; @@ -2806,7 +2803,6 @@ 24E7591E2E0B407F0065BA02 /* SettingsResetPINView.swift in Sources */, 24C8875E2F3DCDC100E14910 /* AudioServicesWrapper.swift in Sources */, 246C42A62F250E96009BC881 /* CalloutTextView.swift in Sources */, - 24F5BD4A2E0442D00010D340 /* SettingsFooterView.swift in Sources */, 24572C762F1E30C0009C72F0 /* BentoNoSendModalView.swift in Sources */, C312D3962D7DC27700BB97A4 /* LAContext+Extensions.swift in Sources */, 24006C2A2F0FCCA500F29F0A /* BentoSendCompletedView.swift in Sources */, diff --git a/brainwallet/Additions/UserDefaults+Additions.swift b/brainwallet/Additions/UserDefaults+Additions.swift index 810f9b66..f9e22370 100644 --- a/brainwallet/Additions/UserDefaults+Additions.swift +++ b/brainwallet/Additions/UserDefaults+Additions.swift @@ -26,6 +26,7 @@ let numberOfBrainwalletLaunches = "NumberOfBrainwalletLaunches" let appHasRequestedReviewKey = "appHasRequestedReviewKey" let userDidPreferDarkModeKey = "UserDidPreferDarkMode" let userCurrentLocaleMPApprovedKey = "UserCurrentLocaleMPApproved" +let userSetPreferredNetworkFeeKey = "UserSetPreferredNetworkFeeKey" public extension NSNotification.Name { static let walletBalanceChangedNotification = NSNotification.Name("WalletBalanceChanged") @@ -48,7 +49,19 @@ public extension NSNotification.Name { } extension UserDefaults { - + + + static var userSetPreferredNetworkFee: Int { + get { + guard defaults.object(forKey: userSetPreferredNetworkFeeKey) != nil + else { + return Int(Fees.usingDefaultValues.luxury) + } + return defaults.integer(forKey: userSetPreferredNetworkFeeKey) + } + set { defaults.set(newValue, forKey: userSetPreferredNetworkFeeKey) } + } + static var userCanBuyInCurrentLocale: Bool { get { guard defaults.object(forKey: userCurrentLocaleMPApprovedKey) != nil diff --git a/brainwallet/FeeManager.swift b/brainwallet/FeeManager.swift index 5d4b0a47..6cc29996 100644 --- a/brainwallet/FeeManager.swift +++ b/brainwallet/FeeManager.swift @@ -19,3 +19,38 @@ struct Fees: Equatable { timestamp: defaultTimestamp) } } + +/// The 3 selectable fee-per-kb tiers shown by the Settings network fee picker +/// (SettingsLitecoinDetailView), split out from the view so the math is unit-testable +/// without instantiating SwiftUI state. +enum NetworkFeeTier: Int, CaseIterable { + case economy = 0 + case regular = 1 + case luxury = 2 + + /// The tier's fee-per-kb, in litoshis. + func litoshis(from fees: Fees) -> UInt64 { + switch self { + case .economy: return fees.economy + case .regular: return fees.regular + case .luxury: return fees.luxury + } + } + + /// The tier whose fee (in litoshis) is numerically closest to `storedLitoshis` + /// (e.g. a previously persisted `UserDefaults.userSetPreferredNetworkFee`). + /// Ties favor the lower tier (economy over regular, regular over luxury). + static func closest(to storedLitoshis: Int, in fees: Fees) -> NetworkFeeTier { + allCases.min { + abs(Int($0.litoshis(from: fees)) - storedLitoshis) < abs(Int($1.litoshis(from: fees)) - storedLitoshis) + } ?? .regular + } + + /// Converts a litoshi fee-per-kb into a formatted fiat string (e.g. "12.34 USD"), + /// or "" when the calculated amount is zero (no fee, or no rate loaded yet). + static func formattedFiatAmount(litoshis: UInt64, rate: Rate) -> String { + let feeInLTC = Double(litoshis) / Double(C.litoshis) + let calculation = feeInLTC * rate.rate + return calculation == 0 ? "" : String(format: "%.3f %@", calculation, rate.code) + } +} diff --git a/brainwallet/Localizations/Localizable.xcstrings b/brainwallet/Localizations/Localizable.xcstrings index 7c93f511..9be909c2 100644 --- a/brainwallet/Localizations/Localizable.xcstrings +++ b/brainwallet/Localizations/Localizable.xcstrings @@ -1802,6 +1802,9 @@ } } } + }, + "%llu ł" : { + }, "1. Check your balance" : { "comment" : "Title for the first step in the send flow.", @@ -2594,6 +2597,9 @@ } } } + }, + "A higher value means the transaction completes sooner" : { + }, "Add 3 more emojis, and you have a dozen." : { "comment" : "Guide detail text for the final emoji triplet selection screen.", @@ -7031,6 +7037,9 @@ } } } + }, + "Choose preferred fee:" : { + }, "Choose your 3 emojis for the game" : { "localizations" : { @@ -12643,6 +12652,7 @@ } }, "Found transactions are added to the device's database and added or subtracted creating your Brainwallet's balance. There is no Litecoin in any wallet. Litecoin is only found on the network. Brainwallet is a simple way to check your transaction history and it stores your private keys" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -12771,6 +12781,9 @@ } } } + }, + "Full Blockchain sync:" : { + }, "Game & Sync" : { "localizations" : { @@ -14995,6 +15008,9 @@ } } } + }, + "If transactions are missing you can run a blockchain sync. This can take >3 hours." : { + }, "If you saved 12 word seed phrase on your phone or had it on Litewallet, you can restore your Brainwallet here." : { "localizations" : { @@ -17991,6 +18007,9 @@ } } } + }, + "Network Fee (per kb):" : { + }, "Network fees" : { "localizations" : { @@ -19688,6 +19707,9 @@ } } } + }, + "Peer sync mode" : { + }, "Pick 3 Emojis" : { "localizations" : { @@ -26206,6 +26228,12 @@ } } } + }, + "Set from spv peers in the Litecoin mainnet or a trusted peer to sync with the blockchain" : { + + }, + "Set IP Address" : { + }, "Set PIN" : { "localizations" : { @@ -28294,6 +28322,7 @@ } }, "Sync to the Litecoin Blockchain" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -28424,6 +28453,7 @@ } }, "Sync with Blockchain?" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -28554,6 +28584,7 @@ } }, "Syncing is a process where Brainwallet scans the Litecoin blockchain to see if any transactions that match your seed words (private keys) are in any transactions." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -31557,6 +31588,7 @@ } }, "This process can take 5 - 45 mins depending on your connectivty to the Litecoin network." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -32466,6 +32498,9 @@ } } } + }, + "Toggle mode" : { + }, "TOP SECRET" : { "extractionState" : "stale", @@ -36643,6 +36678,7 @@ } }, "You will not be able to send Litecoin while syncing. It may take a while." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { diff --git a/brainwallet/New Main Classes/NewMainViewModel.swift b/brainwallet/New Main Classes/NewMainViewModel.swift index 64314729..82ae7c69 100644 --- a/brainwallet/New Main Classes/NewMainViewModel.swift +++ b/brainwallet/New Main Classes/NewMainViewModel.swift @@ -125,6 +125,11 @@ class NewMainViewModel: ObservableObject, Subscriber { @Published var currentNetworkFee = Litecoin(rawValue: 0.0) + /// Tiered (economy/regular/luxury) fee-per-kb, sourced from BWAPIClient.feePerKb(_:) + /// (GET v1/fee-per-kb) — backs the Network Fee picker in SettingsLitecoinDetailView. + @Published + var currentFees: Fees = .usingDefaultValues + @Published var currentPreFeeAmount = Litecoin(rawValue: 0.0) @@ -253,6 +258,20 @@ class NewMainViewModel: ObservableObject, Subscriber { dateFormatter!.setLocalizedDateFormatFromTemplate("dd MMM hh:mm:ss a") setBalances() updateTransactions() + fetchNetworkFees() + } + + /// Fetches the tiered fee-per-kb from the mobile-api (v1/fee-per-kb) and + /// publishes it as `currentFees` for the Settings network fee picker. + private func fetchNetworkFees() { + walletManager?.apiClient?.feePerKb { [weak self] fees, error in + guard let self else { return } + if let error { + debugPrint("::: ERROR: fee-per-kb fetch failed: \(error)") + return + } + self.currentFees = fees + } } deinit { diff --git a/brainwallet/Settings Classes/CurrencyPickerView.swift b/brainwallet/Settings Classes/CurrencyPickerView.swift index 743eda9b..9028e2ea 100644 --- a/brainwallet/Settings Classes/CurrencyPickerView.swift +++ b/brainwallet/Settings Classes/CurrencyPickerView.swift @@ -85,9 +85,9 @@ struct CurrencyPickerView: View { Spacer() } .frame(width: width * 0.1, alignment: .leading) - .background(BrainwalletColor.background) + .background(BrainwalletColor.surface) } - .background(BrainwalletColor.background) + .background(BrainwalletColor.surface) } } diff --git a/brainwallet/Settings Classes/SettingsActionLockView.swift b/brainwallet/Settings Classes/SettingsActionLockView.swift index b0d3f832..e01086fe 100644 --- a/brainwallet/Settings Classes/SettingsActionLockView.swift +++ b/brainwallet/Settings Classes/SettingsActionLockView.swift @@ -25,45 +25,32 @@ struct SettingsActionLockView: View { } var body: some View { - NavigationStack { - GeometryReader { _ in - ZStack { - HStack { - VStack { - Text(title) - .modifier(BWIPSSemiBold(size: 19.0)) + VStack(alignment: .leading, spacing: 0.0) { + Divider() + .frame(height: 1) + .overlay(Color.white) + Text(title) + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: closedRowHeight) + .overlay(alignment: .trailing) { + Button(action: { + didTriggerLock.toggle() + }) { + Image(systemName: didTriggerLock ? action.isOnSystemImage : action.isOffSystemImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30.0, + height: 30.0) .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 25.0) - .padding(.top, 8.0) - Text(detailText) - .modifier(BWIPSLight(size: 18.0)) - .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - .padding([.top, .bottom], 1.0) - Spacer() - } - Spacer() - VStack { - Button(action: { - didTriggerLock.toggle() - }) { - VStack { - Image(systemName: didTriggerLock ? action.isOnSystemImage : action.isOffSystemImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 30.0, - height: 30.0) - .foregroundColor(BrainwalletColor.content) - Spacer() - } - } - .frame(width: 30.0, height: 30.0) - } } + .frame(width: 30.0, height: 30.0) + .contentShape(Rectangle()) } - } } + .frame(maxWidth: .infinity, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/brainwallet/Settings Classes/SettingsActionThemeView.swift b/brainwallet/Settings Classes/SettingsActionThemeView.swift index 0cbc713b..516ee7db 100644 --- a/brainwallet/Settings Classes/SettingsActionThemeView.swift +++ b/brainwallet/Settings Classes/SettingsActionThemeView.swift @@ -10,59 +10,42 @@ import SwiftUI struct SettingsActionThemeView: View { private let title: String - private let detailText: String let action: SettingsAction @Binding var userPrefersDark: Bool - init(title: String, detailText: String, action: SettingsAction, userPrefersDark: Binding) { + init(title: String, action: SettingsAction, userPrefersDark: Binding) { self.title = title - self.detailText = detailText self.action = action _userPrefersDark = userPrefersDark } var body: some View { - NavigationStack { - GeometryReader { _ in - ZStack { - HStack { - VStack { - Text(title) - .modifier(BWIPSSemiBold(size: 19.0)) - .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 25.0) - .padding(.top, 8.0) - Text(detailText) - .modifier(BWIPSLight(size: 18.0)) - .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - .padding([.top, .bottom], 1.0) - Spacer() - } - - Spacer() - VStack { - Button(action: { userPrefersDark.toggle() }) { - VStack { - Image(systemName: userPrefersDark ? action.isOffSystemImage : action.isOnSystemImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 30.0, - height: 30.0) - .foregroundColor(BrainwalletColor.content) - .padding(20.0) - Spacer() - } - } + + VStack(alignment: .leading, spacing: 0.0) { + Divider() + .frame(height: 1) + .overlay(Color.white) + Text(title) + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: closedRowHeight) + .overlay(alignment: .trailing) { + Button(action: { userPrefersDark.toggle() }) { + Image(systemName: userPrefersDark ? action.isOffSystemImage : action.isOnSystemImage) + .resizable() + .aspectRatio(contentMode: .fit) .frame(width: 30.0, height: 30.0) - } + .foregroundColor(BrainwalletColor.content) } + .frame(width: 30.0, height: 30.0) + .contentShape(Rectangle()) } - } } + .frame(maxWidth: .infinity, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift b/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift index 17572421..f87bbcc2 100644 --- a/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift +++ b/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift @@ -19,73 +19,69 @@ struct SettingsExpandingBlockchainView: View { @State private var willSync: Bool = false + + @State + private var userWantsTrustedNode: Bool = false private var title: String + /// Height of the detail panel when expanded; total expanded row = closedRowHeight + this. + private let detailExpandedHeight: CGFloat = 300.0 + init(title: String, viewModel: NewMainViewModel, shouldExpandBlockchain: Binding ) { self.title = title _shouldExpandBlockchain = shouldExpandBlockchain self.viewModel = viewModel } - var body: some View { - NavigationStack { - GeometryReader { _ in - ZStack { - VStack { - HStack { - VStack { - Text(title) - .modifier(BWIPSSemiBold(size: 19.0)) - .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 25.0) - .padding(.top, 8.0) - } + @ViewBuilder + private var blockchainDetail: some View { + SettingsLitecoinDetailView(willSync: $willSync, + userWantsTrustedNode: $userWantsTrustedNode, + fees: viewModel.currentFees, + currentRate: viewModel.exchangeRate) + .frame(maxWidth: .infinity) + } - Spacer() - VStack { - Button(action: { - shouldExpandBlockchain.toggle() - }) { - VStack { - HStack { - Image(systemName: "chevron.right") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: expandArrowSize, - height: expandArrowSize) - .foregroundColor(BrainwalletColor.content) - .rotationEffect(Angle(degrees: shouldExpandBlockchain ? 90 : 0)) - } - } - .frame(width: 30.0, height: 30.0, alignment: .top) - .padding(.top, 9.0) - } - .frame(width: 30.0, height: 30.0) - } + var body: some View { + VStack(alignment: .leading, spacing: 0.0) { + Divider() + .frame(height: 1) + .overlay(Color.white) + Text(title) + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leadingFirstTextBaseline) + .frame(height: closedRowHeight) + .overlay(alignment: .trailing) { + Button(action: { + withAnimation(.easeInOut(duration: 0.3)) { + shouldExpandBlockchain.toggle() } - .padding(.top, 1.0) - SettingsLitecoinDetailView(willSync: $willSync) - .transition(.opacity) - .transition(.slide) - .frame(height: shouldExpandBlockchain ? 200 : 0.1) - .animation(.easeInOut(duration: 0.3), value: shouldExpandBlockchain) - Spacer() + let impactMed = UIImpactFeedbackGenerator(style: .medium) + impactMed.impactOccurred() + }) { + Image(systemName: "chevron.down") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: expandArrowSize, height: expandArrowSize) + .foregroundColor(BrainwalletColor.content) + .rotationEffect(Angle(degrees: shouldExpandBlockchain ? 180 : 0)) } - .alert(String(localized: "Sync with Blockchain?"), - isPresented: $willSync, - actions: { - Button(String(localized: "Cancel"), role: .cancel) { } - Button( String(localized: "Ok"), role: .destructive) { - viewModel.userWillSyncBlockchain() - } - }, - message: { - Text("You will not be able to send Litecoin while syncing. It may take a while.") - }) + .frame(width: 30.0, height: 30.0) + .contentShape(Rectangle()) } + + ScrollView(.vertical) { + blockchainDetail } + .frame(height: shouldExpandBlockchain ? detailExpandedHeight : 0.0, alignment: .top) + .clipped() + .opacity(shouldExpandBlockchain ? 1.0 : 0.0) + .allowsHitTesting(shouldExpandBlockchain) + .scrollIndicators(.visible) } + .frame(maxWidth: .infinity, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/brainwallet/Settings Classes/SettingsExpandingCurrencyView.swift b/brainwallet/Settings Classes/SettingsExpandingCurrencyView.swift index 17954af8..7e92ca8e 100644 --- a/brainwallet/Settings Classes/SettingsExpandingCurrencyView.swift +++ b/brainwallet/Settings Classes/SettingsExpandingCurrencyView.swift @@ -24,6 +24,12 @@ struct SettingsExpandingCurrencyView: View { private var pickedCurrency: GlobalCurrency = .USD private var title: String + + /// Height of the detail panel when expanded; total expanded row = closedRowHeight + this. + private let detailExpandedHeight: CGFloat = 120.0 + + //let pickerViewHeight: CGFloat = 160.0 + init(title: String, viewModel: NewMainViewModel, shouldExpandCurrency: Binding ) { self.title = title @@ -32,64 +38,41 @@ struct SettingsExpandingCurrencyView: View { } var body: some View { - NavigationStack { - GeometryReader { _ in - ZStack { - VStack { - HStack { - VStack { - Text("\(title) (\(pickedCurrency.symbol))") - .modifier(BWIPSSemiBold(size: 19.0)) - .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 25.0) - .padding(.top, 8.0) - } - Spacer() - - VStack { - Button(action: { - shouldExpandCurrency.toggle() - let impactRigid = UIImpactFeedbackGenerator(style: .rigid) - impactRigid.impactOccurred() - }) { - VStack { - HStack { - Image(systemName: "chevron.right") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: expandArrowSize, height: expandArrowSize) - .foregroundColor(BrainwalletColor.content) - .rotationEffect(Angle(degrees: shouldExpandCurrency ? 90 : 0)) - - } - - } - .frame(width: 30.0, height: 30.0, alignment: .top) - .padding(.top, 9.0) - - } - .frame(width: 30.0, height: 30.0) - } - .frame(width: 30.0, height: 50.0) - + VStack(alignment: .leading, spacing: 0.0) { + Divider() + .frame(height: 1) + .overlay(Color.white) + Text("\(title) (\(pickedCurrency.symbol))") + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leadingFirstTextBaseline) + .frame(height: closedRowHeight) + .overlay(alignment: .trailing) { + Button(action: { + withAnimation(.easeInOut(duration: 0.3)) { + shouldExpandCurrency.toggle() } - .padding(.top, 1.0) - .padding(.bottom, 8.0) - CurrencyPickerView(viewModel: viewModel, pickedCurrency: $pickedCurrency) - .transition(.opacity) - .animation(.easeInOut(duration: 0.3), value: shouldExpandCurrency) - .frame(height: shouldExpandCurrency ? pickerViewHeight : 0.1) - Spacer() - } - .onChange(of: viewModel.currentGlobalFiat, { _, _ in - pickedCurrency = viewModel.currentGlobalFiat - }) - .onAppear { - pickedCurrency = viewModel.currentGlobalFiat + let impactMed = UIImpactFeedbackGenerator(style: .medium) + impactMed.impactOccurred() + }) { + Image(systemName: "chevron.down") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: expandArrowSize, height: expandArrowSize) + .foregroundColor(BrainwalletColor.content) + .rotationEffect(Angle(degrees: shouldExpandCurrency ? 180 : 0)) } + .frame(width: 30.0, height: 30.0) + .contentShape(Rectangle()) } - } + + CurrencyPickerView(viewModel: viewModel, pickedCurrency: $pickedCurrency) + .transition(.opacity) + .animation(.easeInOut(duration: 0.3), value: shouldExpandCurrency) + .frame(height: shouldExpandCurrency ? detailExpandedHeight : 0.0) + .opacity(shouldExpandCurrency ? 1.0 : 0.0) } + .frame(maxWidth: .infinity, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/brainwallet/Settings Classes/SettingsExpandingSecurityView.swift b/brainwallet/Settings Classes/SettingsExpandingSecurityView.swift index 9c2ea4eb..612cf169 100644 --- a/brainwallet/Settings Classes/SettingsExpandingSecurityView.swift +++ b/brainwallet/Settings Classes/SettingsExpandingSecurityView.swift @@ -21,6 +21,9 @@ struct SettingsExpandingSecurityView: View { var securityListView: SecurityListView + /// Height of the detail panel when expanded; total expanded row = closedRowHeight + this. + private let detailExpandedHeight: CGFloat = 250.0 + init(title: String, viewModel: NewMainViewModel, shouldExpandSecurity: Binding ) { self.title = title _shouldExpandSecurity = shouldExpandSecurity @@ -29,56 +32,42 @@ struct SettingsExpandingSecurityView: View { } var body: some View { - NavigationStack { - GeometryReader { _ in - ZStack { - VStack { - HStack { - VStack { - Text(title) - .modifier(BWIPSSemiBold(size: 19.0)) - .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 25.0) - .padding(.top, 8.0) - } - - Spacer() - VStack { - Button(action: { - withAnimation(.easeInOut(duration: 0.3)) { - shouldExpandSecurity.toggle() - } - let impactMed = UIImpactFeedbackGenerator(style: .medium) - impactMed.impactOccurred() - }) { - VStack { - HStack { - Image(systemName: "chevron.right") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: expandArrowSize, height: expandArrowSize) - .foregroundColor(BrainwalletColor.content) - .rotationEffect(Angle(degrees: shouldExpandSecurity ? 90 : 0)) - } - } - .frame(width: 30.0, height: 30.0, alignment: .top) - .padding(.top, 11.0) - } - .frame(width: 30.0, height: 30.0) - } + VStack(alignment: .leading, spacing: 0.0) { + Divider() + .frame(height: 1) + .overlay(Color.white) + Text(title) + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leadingFirstTextBaseline) + .frame(height: closedRowHeight) + .overlay(alignment: .trailing) { + Button(action: { + withAnimation(.easeInOut(duration: 0.3)) { + shouldExpandSecurity.toggle() } - .padding(.top, 1.0) - SecurityListView(viewModel: viewModel) - .transition(.opacity) - .transition(.move(edge: .top)) - .animation(.easeInOut(duration: 0.3), value: shouldExpandSecurity) - .padding(.top, 16.0) - Spacer() + let impactMed = UIImpactFeedbackGenerator(style: .medium) + impactMed.impactOccurred() + }) { + Image(systemName: "chevron.down") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: expandArrowSize, height: expandArrowSize) + .foregroundColor(BrainwalletColor.content) + .rotationEffect(Angle(degrees: shouldExpandSecurity ? 180 : 0)) } - + .frame(width: 30.0, height: 30.0) + .contentShape(Rectangle()) } - } + + SecurityListView(viewModel: viewModel) + .frame(maxWidth: .infinity) + .frame(height: shouldExpandSecurity ? detailExpandedHeight : 0.0, alignment: .top) + .clipped() + .opacity(shouldExpandSecurity ? 1.0 : 0.0) + .allowsHitTesting(shouldExpandSecurity) } + .frame(maxWidth: .infinity, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/brainwallet/Settings Classes/SettingsFooterView.swift b/brainwallet/Settings Classes/SettingsFooterView.swift deleted file mode 100644 index 3c1acbac..00000000 --- a/brainwallet/Settings Classes/SettingsFooterView.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// SettingsFooterView.swift -// brainwallet -// -// Created by Kerry Washington on 19/06/2025. -// Copyright © 2025 Grunt Software, LTD. All rights reserved. -// -import SwiftUI - -struct SettingsFooterView: View { - - private let titleFont: Font = .ibmPlexSansSemiBold(size: 19.0) - private let darkBackgroundColor: Color = BrainwalletColor.nearBlack - private let lightBackgroundColor: Color = BrainwalletColor.grape - private var setBackgroundColor: Color = BrainwalletColor.grape - - let userPreferredDarkMode = UserDefaults.userPreferredDarkTheme - init() { - setBackgroundColor = userPreferredDarkMode ? darkBackgroundColor : lightBackgroundColor - } - - var body: some View { - GeometryReader { _ in - - ZStack { - setBackgroundColor.edgesIgnoringSafeArea(.all) - HStack { - Text("App Version:") - .foregroundColor(.white) - .font(titleFont) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 44.0) - Spacer() - Text("\(AppVersion.string)") - .foregroundColor(.white) - .modifier(BWIPSLight(size: 18.0)) - .padding(.trailing, 16.0) - - } - .padding(.top, 8.0) - - } - } - } -} diff --git a/brainwallet/Settings Classes/SettingsHelpers.swift b/brainwallet/Settings Classes/SettingsHelpers.swift index 30335469..f8578966 100644 --- a/brainwallet/Settings Classes/SettingsHelpers.swift +++ b/brainwallet/Settings Classes/SettingsHelpers.swift @@ -7,7 +7,7 @@ // import SwiftUI -let closedRowHeight: CGFloat = 65.0 +let closedRowHeight: CGFloat = 50.0 let toggleRowHeight: CGFloat = 50.0 let updatePINRowHeight: CGFloat = 100.0 let phraseRowHeight: CGFloat = 60.0 @@ -15,7 +15,7 @@ let expandedRowHeight: CGFloat = 240.0 let rowLeadingPad: CGFloat = 32.0 let leadRowPad: CGFloat = 40.0 let trailRowPad: CGFloat = 16.0 -let expandArrowSize: CGFloat = 20.0 +let expandArrowSize: CGFloat = 16.0 let pickerViewHeight: CGFloat = 160.0 enum SettingsAction: CaseIterable { diff --git a/brainwallet/Settings Classes/SettingsLabelView.swift b/brainwallet/Settings Classes/SettingsLabelView.swift index 23d5dd30..6be1e481 100644 --- a/brainwallet/Settings Classes/SettingsLabelView.swift +++ b/brainwallet/Settings Classes/SettingsLabelView.swift @@ -20,27 +20,26 @@ struct SettingsLabelView: View { } var body: some View { - NavigationStack { - GeometryReader { _ in - ZStack { - rowBackgroundColor.edgesIgnoringSafeArea(.all) - VStack { - Text(title) - .modifier(BWIPSSemiBold(size: 19.0)) - .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 25.0) - .padding(.top, 8.0) - Text(detailText) - .modifier(BWIPSRegular(size: 19.0)) - .kerning(0.2) - .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 21.0, alignment: .center) - .padding(.bottom, 11.0) - } - } + VStack(alignment: .leading, spacing: 0.0) { + Divider() + .frame(height: 1) + .overlay(Color.white) + VStack(alignment: .leading, spacing: 2.0) { + Text(title) + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leading) + Text(detailText) + .modifier(BWIPSRegular(size: 14.0)) + .kerning(0.2) + .foregroundColor(BrainwalletColor.content.opacity(0.8)) + .frame(maxWidth: .infinity, alignment: .leading) } + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: closedRowHeight) } + .frame(maxWidth: .infinity, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + .background(rowBackgroundColor) } } diff --git a/brainwallet/Settings Classes/SettingsLitecoinDetailView.swift b/brainwallet/Settings Classes/SettingsLitecoinDetailView.swift index aa7bc912..31ed69d8 100644 --- a/brainwallet/Settings Classes/SettingsLitecoinDetailView.swift +++ b/brainwallet/Settings Classes/SettingsLitecoinDetailView.swift @@ -10,121 +10,203 @@ import FirebaseAnalytics struct SettingsLitecoinDetailView: View { - let detailFont: Font = .ibmPlexSansLight(size: 15.0) - let dotSize: CGFloat = 12.0 - - let litecoinSyncDetailA: String = String(localized: "Syncing is a process where Brainwallet scans the Litecoin blockchain to see if any transactions that match your seed words (private keys) are in any transactions.") - let litecoinSyncDetailB: String = String(localized: "Found transactions are added to the device's database and added or subtracted creating your Brainwallet's balance. There is no Litecoin in any wallet. Litecoin is only found on the network. Brainwallet is a simple way to check your transaction history and it stores your private keys") - let litecoinSyncDetailC: String = String(localized: "This process can take 5 - 45 mins depending on your connectivty to the Litecoin network.") + private let syncButtonHeight: CGFloat = 40.0 + private let syncButtonWidth: CGFloat = 140.0 @Binding var willSync: Bool - init(willSync: Binding) { + @Binding + var userWantsTrustedNode: Bool + + /// Tiered fee-per-kb (economy/regular/luxury), sourced from + /// BWAPIClient.feePerKb(_:) (GET v1/fee-per-kb) via NewMainViewModel.currentFees. + let fees: Fees + + let rate: Rate + + @State + private var feesPerFiat = "" + @State + private var selectedNetworkFeeIndex = 1 + + init(willSync: Binding, + userWantsTrustedNode: Binding, + fees: Fees, + currentRate: Rate?) { _willSync = willSync + _userWantsTrustedNode = userWantsTrustedNode + self.fees = fees + self.rate = currentRate ?? Rate(code: "", name: "", rate: 0.0, lastTimestamp: Date()) + } + + /// Litoshis (fee-per-kb) for the currently selected Picker tag (0 = economy, 1 = regular, 2 = luxury). + /// Fee tier math lives in `NetworkFeeTier` (FeeManager.swift) so it's unit-testable on its own. + private func feeInLitoshis(forTag tag: Int) -> UInt64 { + (NetworkFeeTier(rawValue: tag) ?? .regular).litoshis(from: fees) + } + + /// Converts the selected fee from litoshis to fiat and persists it as the user's + /// preferred network fee. + private func updateFeesPerFiat(litoshis: UInt64) { + feesPerFiat = NetworkFeeTier.formattedFiatAmount(litoshis: litoshis, rate: rate) + UserDefaults.userSetPreferredNetworkFee = Int(litoshis) + } + + /// Finds which of the 3 fee tiers (economy/regular/luxury -> 0/1/2) is numerically + /// closest to a previously stored litoshi fee value. + private func closestFeeIndex(toLitoshis storedFee: Int) -> Int { + NetworkFeeTier.closest(to: storedFee, in: fees).rawValue } var body: some View { - NavigationStack { - GeometryReader { geometry in - let width = geometry.size.width - let height = geometry.size.height - - ZStack { - List { - HStack { - Text(String(localized: "Sync to the Litecoin Blockchain")) - .modifier(BWIPSSemiBold(size: 19.0)) + VStack(alignment: .leading, spacing: 20.0) { + + // MARK: - Peer sync mode + VStack(alignment: .leading, spacing: 6.0) { + Divider() + .frame(height: 1) + .overlay(Color.white) + + Text(String(localized: "Peer sync mode")) + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(String(localized: "Set from spv peers in the Litecoin mainnet or a trusted peer to sync with the blockchain")) + .modifier(BWIPSRegular(size: 14.0, lineLimit: 2)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 10.0) { + Text(String(localized: "Toggle mode")) + .modifier(BWIPSSemiBold(size: 14.0)) + .foregroundColor(BrainwalletColor.content) + + Toggle(isOn: $userWantsTrustedNode) { + } + .labelsHidden() + .toggleStyle(.switch) + } + + Spacer() + + Button(action: { + + }) { + Text("Set IP Address") + .frame(width: syncButtonWidth, height: syncButtonHeight, + alignment: .center) + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundStyle(.white) + .background( + RoundedRectangle(cornerRadius: syncButtonHeight/2) + .fill(BrainwalletColor.background) + ) + } + .buttonStyle(.plain) + } + .padding(.top, 6.0) + } + + // MARK: - Sync duration + VStack(alignment: .leading, spacing: 0.0) { + Divider() + .frame(height: 1) + .overlay(Color.white) + + HStack(alignment: .center) { + GeometryReader { geo in + VStack(alignment: .center, spacing: 12.0) { + Text(String(localized: "Full Blockchain sync:")) + .modifier(BWIPSSemiBold(size: 15.0)) .foregroundColor(BrainwalletColor.content) .frame(maxWidth: .infinity, alignment: .leading) - Spacer() - } - .frame(height: height * 0.1) - .padding(.top, 1.0) - .background(BrainwalletColor.background) - .listRowBackground(BrainwalletColor.background) - .listRowSeparator(.hidden) - - HStack { - Text(litecoinSyncDetailA) - .modifier(BWIPSLight(size: 18.0)) - .multilineTextAlignment(.leading) + + Text(String(localized: "If transactions are missing you can run a blockchain sync. This can take >3 hours.")) + .modifier(BWIPSRegular(size: 14.0, lineLimit: 3)) .foregroundColor(BrainwalletColor.content) .frame(maxWidth: .infinity, alignment: .leading) - Spacer() } - .padding(.top, 1.0) - .background(BrainwalletColor.background) - .listRowBackground(BrainwalletColor.background) - .listRowSeparator(.hidden) + } + .frame(height: 90.0) + .padding(.top, 2.0) - HStack { + Button(action: { + willSync.toggle() + Analytics.logEvent("did_start_resync", + parameters: nil) + }) { + Text("Sync") + .frame(width: syncButtonWidth, height: syncButtonHeight, + alignment: .center) + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundStyle(.white) + .background( + RoundedRectangle(cornerRadius: syncButtonHeight/2) + .fill(BrainwalletColor.background) + ) + } + .frame(height: 44.0) + .buttonStyle(.plain) + } + .padding(.top, 4.0) + } - Text(litecoinSyncDetailB) - .modifier(BWIPSLight(size: 18.0)) - .multilineTextAlignment(.leading) - .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - Spacer() - } - .padding(.top, 1.0) - .background(BrainwalletColor.background) - .listRowBackground(BrainwalletColor.background) - .listRowSeparator(.hidden) + // MARK: - Network fee + VStack(alignment: .leading, spacing: 6.0) { + Divider() + .frame(height: 1) + .overlay(Color.white) - HStack { + Text(String(localized: "Network Fee (per kb):")) + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leading) - Text(litecoinSyncDetailC) - .modifier(BWIPSLight(size: 18.0)) - .multilineTextAlignment(.leading) - .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - Spacer() + Text(String(localized: "A higher value means the transaction completes sooner")) + .modifier(BWIPSRegular(size: 14.0, lineLimit: 2)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leading) + + GeometryReader { geo in + HStack(alignment: .center, spacing: 8.0) { + Text(String(localized: "Choose preferred fee:")) + .modifier(BWIPSSemiBold(size: 15.0, lineLimit: 1)) + .foregroundColor(BrainwalletColor.content) + + Picker("", selection: $selectedNetworkFeeIndex) { + Text("\(fees.economy) ł").modifier(BWIPSSemiBold(size: 14.0)).tag(0) + Text("\(fees.regular) ł").modifier(BWIPSSemiBold(size: 14.0)).tag(1) + Text("\(fees.luxury) ł").modifier(BWIPSSemiBold(size: 14.0)).tag(2) + } + .pickerStyle(.wheel) + .frame(width: geo.size.width * 0.4, height: 80.0) + .clipped() + .onAppear { + let fetchedFee = UserDefaults.userSetPreferredNetworkFee + selectedNetworkFeeIndex = closestFeeIndex(toLitoshis: fetchedFee) + updateFeesPerFiat(litoshis: feeInLitoshis(forTag: selectedNetworkFeeIndex)) } - .padding(.top, 1.0) - .background(BrainwalletColor.background) - .listRowBackground(BrainwalletColor.background) - .listRowSeparator(.hidden) - .background(BrainwalletColor.background) - HStack { - Button(action: { - willSync.toggle() - Analytics.logEvent("did_start_resync", - parameters: nil) - }) { - ZStack { - RoundedRectangle(cornerRadius: largeButtonHeight/2) - .frame(width: width * 0.9, height: largeButtonHeight, - alignment: .center) - .frame(height: largeButtonHeight, alignment: .center) - .foregroundColor(BrainwalletColor.surface) - - Text("Sync") - .frame(width: width * 0.9, height: largeButtonHeight, - alignment: .center) - .frame(maxWidth: .infinity, alignment: .center) - .modifier(BWIPSSemiBold(size: 19.0)) - .foregroundColor(BrainwalletColor.content) - .overlay( - RoundedRectangle(cornerRadius: largeButtonHeight/2) - .stroke(BrainwalletColor.content, lineWidth: 1.0) - ) - } + .onChange(of: selectedNetworkFeeIndex) { _, _ in + switch selectedNetworkFeeIndex { + case 0: updateFeesPerFiat(litoshis: fees.economy) + case 1: updateFeesPerFiat(litoshis: fees.regular) + case 2: updateFeesPerFiat(litoshis: fees.luxury) + default: updateFeesPerFiat(litoshis: fees.luxury) } } - .frame(height: height * 0.2) - .background(BrainwalletColor.background) - .listRowBackground(BrainwalletColor.background) - .listRowSeparator(.hidden) - .padding(.bottom, 32.0) - Spacer() + Text(feesPerFiat) + .modifier(BWIPSRegular(size: 14.0, lineLimit: 1)) + .foregroundColor(BrainwalletColor.content) } - .background(BrainwalletColor.background) - .listRowBackground(BrainwalletColor.background) - .listRowSeparator(.hidden) } + .frame(height: 80.0) + .padding(.top, 2.0) } } + .padding(.bottom, 12.0) } } diff --git a/brainwallet/Settings Classes/SettingsView.swift b/brainwallet/Settings Classes/SettingsView.swift index 0aa655b3..eda0f535 100644 --- a/brainwallet/Settings Classes/SettingsView.swift +++ b/brainwallet/Settings Classes/SettingsView.swift @@ -42,9 +42,6 @@ struct SettingsView: View { @State private var shouldShowSupportSheet: Bool = false - @State - private var tempRowHeight: CGFloat = closedRowHeight - let footerRowHeight: CGFloat = 55.0 let squareButtonSize: CGFloat = 55.0 @@ -78,85 +75,68 @@ struct SettingsView: View { HStack { VStack { - List { - SettingsExpandingSecurityView(title: String(localized: "Security"), - viewModel: newMainViewModel, shouldExpandSecurity: $shouldExpandSecurity) - .frame(height: shouldExpandSecurity ? 300 : tempRowHeight) - .listRowBackground(shouldExpandSecurity ? BrainwalletColor.background : BrainwalletColor.surface) - .listRowInsets(EdgeInsets()) - .listRowSeparatorTint(BrainwalletColor.content) - .padding(.top, leadRowPad) - .padding(.leading, leadRowPad) - .padding(.trailing, trailRowPad) - SettingsExpandingCurrencyView(title: String(localized: "Fiat Currency"), - viewModel: newMainViewModel, shouldExpandCurrency: $shouldExpandCurrency) - .frame(height: shouldExpandCurrency ? pickerViewHeight : tempRowHeight) - .listRowBackground(shouldExpandCurrency ? BrainwalletColor.background : BrainwalletColor.surface) - .listRowInsets(EdgeInsets()) - .listRowSeparatorTint(BrainwalletColor.content) - .padding(.leading, leadRowPad) - .padding(.trailing, trailRowPad) - SettingsExpandingBlockchainView(title: String(localized: "Blockchain: Litecoin"), - viewModel: newMainViewModel, shouldExpandBlockchain: $shouldExpandBlockchain) - .frame(maxWidth: .infinity) - .frame(height: shouldExpandBlockchain ? 200 : tempRowHeight) - .listRowBackground(shouldExpandBlockchain ? BrainwalletColor.background : BrainwalletColor.surface) - .listRowInsets(EdgeInsets()) - .listRowSeparatorTint(BrainwalletColor.content) - .padding(.leading, leadRowPad) - .padding(.trailing, trailRowPad) - SettingsLabelView(title: String(localized: "Social"), - detailText: "linktr.ee/brainwallet") - .frame(height: tempRowHeight) - .listRowBackground(BrainwalletColor.surface) - .listRowInsets(EdgeInsets()) - .listRowSeparatorTint(BrainwalletColor.content) - .padding(.leading, leadRowPad) - .padding(.trailing, trailRowPad) - .onTapGesture { - shouldShowSocialSheet.toggle() - } - SettingsLabelView(title: String(localized: "Support"), - detailText: "support.brainwallet.co") - .frame(height: tempRowHeight) - .listRowBackground(BrainwalletColor.surface) - .listRowInsets(EdgeInsets()) - .listRowSeparatorTint(BrainwalletColor.content) - .padding(.leading, leadRowPad) - .padding(.trailing, trailRowPad) - .onTapGesture { - shouldShowSupportSheet.toggle() - } - SettingsActionThemeView(title: - userPrefersDarkMode ? - String(localized: "Dark Mode") - : String(localized: "Light Mode"), - detailText: "", - action: .preferDarkMode, - userPrefersDark: $userPrefersDarkMode) - .frame(height: tempRowHeight) - .listRowBackground(BrainwalletColor.surface) - .listRowInsets(EdgeInsets()) - .listRowSeparatorTint(BrainwalletColor.content) - .padding(.leading, leadRowPad) - .padding(.trailing, trailRowPad) - - SettingsActionLockView(title: String(localized: "Lock"), - detailText: "", action: .lock, didTriggerLock: $didTriggerLock) - .frame(height: tempRowHeight) - .listRowBackground(BrainwalletColor.surface) - .listRowInsets(EdgeInsets()) - .listRowSeparatorTint(BrainwalletColor.content) - .padding(.leading, leadRowPad) - .padding(.trailing, trailRowPad) + SettingsExpandingSecurityView(title: String(localized: "Security"), + viewModel: newMainViewModel, shouldExpandSecurity: $shouldExpandSecurity) + .frame(maxWidth: .infinity, alignment: .topLeading) + .animation(.easeInOut(duration: 0.3), value: shouldExpandSecurity) + .padding(.top, leadRowPad) + .padding(.leading, leadRowPad) + .padding(.trailing, trailRowPad) + SettingsExpandingCurrencyView(title: String(localized: "Fiat Currency"), + viewModel: newMainViewModel, + shouldExpandCurrency: $shouldExpandCurrency) + .frame(maxWidth: .infinity, alignment: .topLeading) + .animation(.easeInOut(duration: 0.3), value: shouldExpandCurrency) + .padding(.leading, leadRowPad) + .padding(.trailing, trailRowPad) + SettingsExpandingBlockchainView(title: String(localized: "Blockchain: Litecoin"), + viewModel: newMainViewModel, shouldExpandBlockchain: $shouldExpandBlockchain) + .frame(maxWidth: .infinity, alignment: .topLeading) + .animation(.easeInOut(duration: 0.3), value: shouldExpandBlockchain) + .padding(.leading, leadRowPad) + .padding(.trailing, trailRowPad) + SettingsLabelView(title: String(localized: "Social"), + detailText: "linktr.ee/brainwallet") + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.leading, leadRowPad) + .padding(.trailing, trailRowPad) + .onTapGesture { + shouldShowSocialSheet.toggle() + } + SettingsLabelView(title: String(localized: "Support"), + detailText: "support.brainwallet.co") + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.leading, leadRowPad) + .padding(.trailing, trailRowPad) + .onTapGesture { + shouldShowSupportSheet.toggle() } - .listStyle(.plain) - .scrollIndicators(.hidden) - .buttonStyle(PlainButtonStyle()) - SettingsFooterView() - .frame(height: footerRowHeight * 0.4, alignment: .bottom) - .padding(.bottom, 1.0) - .padding(.top, 24.0) + SettingsActionThemeView(title: + userPrefersDarkMode ? + String(localized: "Dark Mode") + : String(localized: "Light Mode"), + action: .preferDarkMode, + userPrefersDark: $userPrefersDarkMode) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.leading, leadRowPad) + .padding(.trailing, trailRowPad) + + SettingsActionLockView(title: String(localized: "Lock"), + detailText: "", action: .lock, didTriggerLock: $didTriggerLock) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.leading, leadRowPad) + .padding(.trailing, trailRowPad) + + SettingsLabelView(title: String(localized: "App Version:"), + detailText: "\(AppVersion.string)") + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.leading, leadRowPad) + .padding(.trailing, trailRowPad) + Divider() + .frame(height: 1) + .frame(width: width * 0.9) + .overlay(Color.white) + Spacer() } .frame(width: width * 0.9) @@ -180,9 +160,10 @@ struct SettingsView: View { } } } - .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global) + .simultaneousGesture(DragGesture(minimumDistance: 20, coordinateSpace: .global) .onEnded({ value in - if value.translation.width < 0 { + let didStartAtLeadingEdge = value.startLocation.x < 24.0 + if didStartAtLeadingEdge && value.translation.width < 0 { newMainViewModel.userDidTapTheSettingsButton() } })) From 5a62dd2170ef2f2c07e64c3b5e34d6a633e68234 Mon Sep 17 00:00:00 2001 From: grunt-claude-bot <308083480+grunt-claude-bot@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:54:15 +0100 Subject: [PATCH 2/5] Restore blockchain sync confirmation alert Co-Authored-By: kcw-grunt --- brainwallet/Localizations/Localizable.xcstrings | 2 -- .../SettingsExpandingBlockchainView.swift | 11 +++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/brainwallet/Localizations/Localizable.xcstrings b/brainwallet/Localizations/Localizable.xcstrings index 9be909c2..594597ad 100644 --- a/brainwallet/Localizations/Localizable.xcstrings +++ b/brainwallet/Localizations/Localizable.xcstrings @@ -28453,7 +28453,6 @@ } }, "Sync with Blockchain?" : { - "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -36678,7 +36677,6 @@ } }, "You will not be able to send Litecoin while syncing. It may take a while." : { - "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { diff --git a/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift b/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift index f87bbcc2..a3288685 100644 --- a/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift +++ b/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift @@ -83,5 +83,16 @@ struct SettingsExpandingBlockchainView: View { } .frame(maxWidth: .infinity, alignment: .topLeading) .fixedSize(horizontal: false, vertical: true) + .alert(String(localized: "Sync with Blockchain?"), + isPresented: $willSync, + actions: { + Button(String(localized: "Cancel"), role: .cancel) { } + Button( String(localized: "Ok"), role: .destructive) { + viewModel.userWillSyncBlockchain() + } + }, + message: { + Text("You will not be able to send Litecoin while syncing. It may take a while.") + }) } } From 367561564387f71c9d09e12eca341deff2eaa191 Mon Sep 17 00:00:00 2001 From: grunt-claude-bot <308083480+grunt-claude-bot@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:36:32 +0100 Subject: [PATCH 3/5] Add Trusted Node feature Co-Authored-By: kcw-grunt --- .gitignore | 12 +- .../contents.xcworkspacedata | 6 + .../brainwallet-local-storekit.xcscheme | 82 +++++ .../xcschemes/brainwallet.xcscheme | 3 - Private/general-purpose | 2 +- brainwallet.xcodeproj/project.pbxproj | 46 +-- .../Additions/UserDefaults+Additions.swift | 50 +++ .../TransactionDetailBentoView.swift | 13 +- brainwallet/Colors/Brainwallet+Color.swift | 16 + .../Localizations/Localizable.xcstrings | 27 ++ .../LockScreenFooterView.swift | 3 - .../LockScreenHostingController.swift | 43 +-- .../LockScreenViewModel.swift | 3 + .../WipeWalletView.swift | 68 ++-- .../New Main Classes/NewMainViewModel.swift | 7 + brainwallet/RemoteConfigHelper.swift | 4 + .../SettingsExpandingBlockchainView.swift | 11 +- .../SettingsLitecoinDetailView.swift | 333 ++++++++++-------- .../Settings Classes/SettingsView.swift | 81 +++-- .../SetTrustedNodeIPModalView.swift | 153 ++++++++ 20 files changed, 692 insertions(+), 271 deletions(-) create mode 100644 BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-local-storekit.xcscheme create mode 100644 brainwallet/TrustedNode Classes/SetTrustedNodeIPModalView.swift diff --git a/.gitignore b/.gitignore index 0a9a1bd6..ba1dbe0b 100644 --- a/.gitignore +++ b/.gitignore @@ -58,15 +58,9 @@ Baseline.json remote-config-defaults.plist service-data.plist GoogleService-Info.plist - -/SourcePackages -/brainwallet/launch-resources/GoogleService-Info.plist -/brainwallet/launch-resources/remote-config-defaults.plist -/brainwallet/launch-resources/service-data.plist -# Storekit -/Storekit -*.storekit -Brainwallet-StoreKit-v1.storekit + +# Storekit +*.storekit /fastlane/report.xml **/fastlane/test_output/ ## Games protection diff --git a/BrainwalletHybrid.xcworkspace/contents.xcworkspacedata b/BrainwalletHybrid.xcworkspace/contents.xcworkspacedata index eb672e17..0062f944 100644 --- a/BrainwalletHybrid.xcworkspace/contents.xcworkspacedata +++ b/BrainwalletHybrid.xcworkspace/contents.xcworkspacedata @@ -1,6 +1,12 @@ + + + + diff --git a/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-local-storekit.xcscheme b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-local-storekit.xcscheme new file mode 100644 index 00000000..6b06dbc9 --- /dev/null +++ b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-local-storekit.xcscheme @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet.xcscheme b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet.xcscheme index 10ef2fdb..97e7d509 100644 --- a/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet.xcscheme +++ b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet.xcscheme @@ -104,9 +104,6 @@ identifier = "San Francisco, CA, USA" referenceType = "1"> - - ) { _userPrefersDarkTheme = userPrefersDarkTheme @@ -270,13 +268,14 @@ struct TransactionDetailBentoView: View { } } .sheet(isPresented: $shouldShowExportSheet) { - WalletProductsModalView(title: .constant(productsTitle), data: exportViewModel.transactionData) - .background(BrainwalletColor.surface) + ExportTransactionsModalView(userPrefersDarkTheme: userPrefersDarkTheme, + data: exportViewModel.transactionData) .cornerRadius(bentoCornerRadius) - .presentationDragIndicator(.hidden) .presentationDetents([.height(height * 0.7)]) - .presentationBackground(.ultraThickMaterial) - .ignoresSafeArea(edges: .bottom) + .presentationDetents([.medium]) + .presentationBackground(BrainwalletColor + .modalBackground(userPrefersDarkTheme: userPrefersDarkTheme)) + .presentationDragIndicator(.visible) } .onAppear { mainGradientStyle = userPrefersDarkTheme ? .darkStyle : .lightStyle diff --git a/brainwallet/Colors/Brainwallet+Color.swift b/brainwallet/Colors/Brainwallet+Color.swift index a38b1cfe..c969c56b 100644 --- a/brainwallet/Colors/Brainwallet+Color.swift +++ b/brainwallet/Colors/Brainwallet+Color.swift @@ -45,6 +45,22 @@ struct BrainwalletColor { } +extension BrainwalletColor { + /// Modal sheet background: midnight for dark theme, lavender for light. + static func modalBackground(userPrefersDarkTheme: Bool) -> Color { + userPrefersDarkTheme ? midnight : lavender + } + + /// Modal primary (title/product name) text color. + static func modalPrimaryText(userPrefersDarkTheme: Bool) -> Color { + userPrefersDarkTheme ? .white : nearBlack + } + + /// Modal secondary (description/divider) text color. + static func modalSecondaryText(userPrefersDarkTheme: Bool) -> Color { + userPrefersDarkTheme ? Color.white.opacity(0.7) : nearBlack.opacity(0.7) + } +} struct BrainwalletUIColor { /// surface - midnight or white diff --git a/brainwallet/Localizations/Localizable.xcstrings b/brainwallet/Localizations/Localizable.xcstrings index 594597ad..7552c108 100644 --- a/brainwallet/Localizations/Localizable.xcstrings +++ b/brainwallet/Localizations/Localizable.xcstrings @@ -10816,6 +10816,9 @@ } } } + }, + "Enter the IPv4 address and port of the Litecoin node your Brainwallet should sync with." : { + }, "Enter your current PIN." : { "extractionState" : "stale", @@ -11991,6 +11994,9 @@ } } } + }, + "Failed to wipe wallet" : { + }, "Failed to wipe wallet." : { "localizations" : { @@ -15271,6 +15277,9 @@ } } } + }, + "IP address" : { + }, "It's your emojis you use to remember your 12 seed words." : { "localizations" : { @@ -16314,6 +16323,9 @@ } } } + }, + "Litecoin Mainnet" : { + }, "Loading Wallet" : { "extractionState" : "stale", @@ -21276,6 +21288,9 @@ } } } + }, + "Please try again." : { + }, "Please write this down" : { "localizations" : { @@ -21406,6 +21421,9 @@ } } } + }, + "Port" : { + }, "POWERED BY MOONPAY" : { "localizations" : { @@ -24142,6 +24160,9 @@ } } } + }, + "Save trusted node" : { + }, "Save your 12 words" : { "localizations" : { @@ -26364,6 +26385,9 @@ } } } + }, + "Set trusted Litecoin node / SPV Peer" : { + }, "Set your first emojis!" : { "comment" : "A title displayed at the top of the emoji picker view.", @@ -35498,6 +35522,9 @@ } } } + }, + "Wiping your wallet…" : { + }, "Word" : { "extractionState" : "stale", diff --git a/brainwallet/New Lock Screen Classes/LockScreenFooterView.swift b/brainwallet/New Lock Screen Classes/LockScreenFooterView.swift index 3d7e0ff1..cabdd154 100644 --- a/brainwallet/New Lock Screen Classes/LockScreenFooterView.swift +++ b/brainwallet/New Lock Screen Classes/LockScreenFooterView.swift @@ -119,9 +119,6 @@ struct LockScreenFooterView: View { .onChange(of: userPrefersDarkMode) { _,_ in viewModel.userDidSetThemePreference(userPrefersDarkMode: userPrefersDarkMode) } - .onChange(of: viewModel.didCompleteWipingWallet) { _,_ in - shouldShowWipeAlert.toggle() - } } } } diff --git a/brainwallet/New Lock Screen Classes/LockScreenHostingController.swift b/brainwallet/New Lock Screen Classes/LockScreenHostingController.swift index f6bed888..d1cbf236 100644 --- a/brainwallet/New Lock Screen Classes/LockScreenHostingController.swift +++ b/brainwallet/New Lock Screen Classes/LockScreenHostingController.swift @@ -63,32 +63,35 @@ class LockScreenHostingController: UIHostingController, Subscrib private func wipeWallet() { guard let walletManager = walletManager else { + viewModel.wipeWalletDidFail = true return } - let group = DispatchGroup() - - group.enter() - DispatchQueue.walletQueue.async { - _ = walletManager.peerManager?.disconnect() - group.leave() - } - - group.enter() DispatchQueue.walletQueue.async { - _ = walletManager.wipeWallet(pin: "forceWipe") - group.leave() - } + walletManager.peerManager?.disconnect() + let didWipeWallet = walletManager.wipeWallet(pin: "forceWipe") + let didDeleteDatabase = walletManager.deleteWalletDatabase(pin: "forceWipe") + + var didWipeKeychainAccess = true + do { + try UserDefaults.wipeTrustedNodeKeychain() + try RemoteConfigHelper.sharedInstance.wipeEnvironmentKeychain() + } catch let error as NSError { + debugPrint("Error | wipeWallet: Failed to wipe keychain: \(error.localizedDescription)") + didWipeKeychainAccess = false + } - group.enter() - DispatchQueue.walletQueue.asyncAfter(deadline: .now() + 1.0) { - _ = walletManager.deleteWalletDatabase(pin: "forceWipe") - group.leave() - } + DispatchQueue.main.async { + guard didWipeWallet, didDeleteDatabase, didWipeKeychainAccess else { + self.viewModel.wipeWalletDidFail = true + return + } - group.notify(queue: .main) { - self.walletWiped() - NotificationCenter.default.post(name: .walletDidWipeNotification, object: nil) + self.walletWiped() + self.store.trigger(name: .reinitWalletManager { + NotificationCenter.default.post(name: .walletDidWipeNotification, object: nil) + }) + } } } diff --git a/brainwallet/New Lock Screen Classes/LockScreenViewModel.swift b/brainwallet/New Lock Screen Classes/LockScreenViewModel.swift index 8d6f5c5c..94b695e8 100644 --- a/brainwallet/New Lock Screen Classes/LockScreenViewModel.swift +++ b/brainwallet/New Lock Screen Classes/LockScreenViewModel.swift @@ -25,6 +25,9 @@ class LockScreenViewModel: ObservableObject, Subscriber { @Published var didCompleteWipingWallet = false + @Published + var wipeWalletDidFail = false + @Published var pinDigits = [Int(),Int(),Int(),Int()] diff --git a/brainwallet/New Lock Screen Classes/WipeWalletView.swift b/brainwallet/New Lock Screen Classes/WipeWalletView.swift index 4f9017bb..8516e0f5 100644 --- a/brainwallet/New Lock Screen Classes/WipeWalletView.swift +++ b/brainwallet/New Lock Screen Classes/WipeWalletView.swift @@ -16,17 +16,25 @@ struct WipeWalletView: View { @Binding var shouldDismiss: Bool + @Binding + var didCompleteWipe: Bool + @State var isWipingWallet: Bool = false let squareImageSize: CGFloat = 25.0 let themeBorderSize: CGFloat = 44.0 + private var isBusy: Bool { + isWipingWallet && !didCompleteWipe && !viewModel.wipeWalletDidFail + } + init(viewModel: LockScreenViewModel, shouldDismiss: Binding, - didCompleteWipe: Binding,) { + didCompleteWipe: Binding) { self.viewModel = viewModel _shouldDismiss = shouldDismiss + _didCompleteWipe = didCompleteWipe } var body: some View { @@ -51,7 +59,37 @@ struct WipeWalletView: View { Spacer() - if !isWipingWallet { + if viewModel.wipeWalletDidFail { + Text("Failed to wipe wallet") + .modifier(BWIPSRegular(size: 24.0)) + .foregroundColor(BrainwalletColor.content) + .frame(alignment: .center) + .padding(.all, 20.0) + + Text("Please try again.") + .modifier(BWIPSRegular(size: 24.0, lineLimit: 2)) + .foregroundColor(BrainwalletColor.content) + .frame(alignment: .center) + .padding(.all, 20.0) + } else if didCompleteWipe { + Text("Wallet is deleted") + .modifier(BWIPSRegular(size: 24.0)) + .foregroundColor(BrainwalletColor.content) + .frame(alignment: .center) + .padding(.all, 20.0) + + Text("Start over by swiping up and restarting the app.") + .modifier(BWIPSRegular(size: 24.0, lineLimit: 2)) + .foregroundColor(BrainwalletColor.content) + .frame(alignment: .center) + .padding(.all, 20.0) + } else if isWipingWallet { + Text("Wiping your wallet…") + .modifier(BWIPSRegular(size: 24.0)) + .foregroundColor(BrainwalletColor.content) + .frame(alignment: .center) + .padding(.all, 20.0) + } else { Text("This will erase your PIN, data & memos. This cannot be undone.") .modifier(BWIPSRegular(size: 24.0, lineLimit: 2)) .foregroundColor(BrainwalletColor.content) @@ -72,26 +110,13 @@ struct WipeWalletView: View { .foregroundColor(BrainwalletColor.content) .tint(BrainwalletColor.surface) .padding(.all, 20.0) - } else { - Text("Wallet is deleted") - .modifier(BWIPSRegular(size: 24.0)) - .foregroundColor(BrainwalletColor.content) - .frame(alignment: .center) - .padding(.all, 20.0) - - Text("Start over by swiping up and restarting the app.") - .modifier(BWIPSRegular(size: 24.0, lineLimit: 2)) - .foregroundColor(BrainwalletColor.content) - .frame(alignment: .center) - .padding(.all, 20.0) } Spacer() Button(action: { - isWipingWallet.toggle() - delay(4.0) { - viewModel.startWipeProcess() - } + viewModel.wipeWalletDidFail = false + isWipingWallet = true + viewModel.startWipeProcess() }) { ZStack { RoundedRectangle(cornerRadius: largeButtonHeight/2) @@ -113,6 +138,7 @@ struct WipeWalletView: View { } .padding(.all, 8.0) } + .disabled(isBusy || didCompleteWipe) .accessibilityIdentifier("Wipe Button") Button(action: { @@ -126,14 +152,14 @@ struct WipeWalletView: View { Text("Cancel") .frame(width: width * 0.9, height: largeButtonHeight, alignment: .center) .modifier(BWIPSBold(size: 24.0)) - .foregroundColor( isWipingWallet ? BrainwalletColor.content.opacity(0.2) : BrainwalletColor.content) + .foregroundColor( isBusy ? BrainwalletColor.content.opacity(0.2) : BrainwalletColor.content) .overlay( RoundedRectangle(cornerRadius: largeButtonHeight/2) - .stroke(isWipingWallet ? BrainwalletColor.content.opacity(0.2) : BrainwalletColor.content, lineWidth: 2.0) + .stroke(isBusy ? BrainwalletColor.content.opacity(0.2) : BrainwalletColor.content, lineWidth: 2.0) ) } .padding(.all, 8.0) - .disabled(isWipingWallet) + .disabled(isBusy) .accessibilityIdentifier("Cancel") } } diff --git a/brainwallet/New Main Classes/NewMainViewModel.swift b/brainwallet/New Main Classes/NewMainViewModel.swift index 82ae7c69..fd5ebe59 100644 --- a/brainwallet/New Main Classes/NewMainViewModel.swift +++ b/brainwallet/New Main Classes/NewMainViewModel.swift @@ -219,6 +219,13 @@ class NewMainViewModel: ObservableObject, Subscriber { @Published var canSelect: Bool = false + + + @Published + var shouldShowTrustedNodeSheet: Bool = false + + @Published + var shouldShowEditTrustedIPAddress: Bool = false init(store: Store, walletManager: WalletManager) { self.store = store diff --git a/brainwallet/RemoteConfigHelper.swift b/brainwallet/RemoteConfigHelper.swift index a37c6df5..59013210 100644 --- a/brainwallet/RemoteConfigHelper.swift +++ b/brainwallet/RemoteConfigHelper.swift @@ -30,6 +30,10 @@ class RemoteConfigHelper: NSObject { deinit {} + func wipeEnvironmentKeychain() throws { + try keychainEnvironment.removeAll() + } + private func setupRemoteConfig() { remoteConfig.setDefaults(fromPlist: "remote-config-defaults") #if DEBUG diff --git a/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift b/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift index a3288685..7e8c347b 100644 --- a/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift +++ b/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift @@ -19,16 +19,15 @@ struct SettingsExpandingBlockchainView: View { @State private var willSync: Bool = false - - @State - private var userWantsTrustedNode: Bool = false + private var title: String /// Height of the detail panel when expanded; total expanded row = closedRowHeight + this. - private let detailExpandedHeight: CGFloat = 300.0 + private let detailExpandedHeight: CGFloat = 340.0 - init(title: String, viewModel: NewMainViewModel, shouldExpandBlockchain: Binding ) { + init(title: String, viewModel: NewMainViewModel, + shouldExpandBlockchain: Binding ) { self.title = title _shouldExpandBlockchain = shouldExpandBlockchain self.viewModel = viewModel @@ -37,10 +36,10 @@ struct SettingsExpandingBlockchainView: View { @ViewBuilder private var blockchainDetail: some View { SettingsLitecoinDetailView(willSync: $willSync, - userWantsTrustedNode: $userWantsTrustedNode, fees: viewModel.currentFees, currentRate: viewModel.exchangeRate) .frame(maxWidth: .infinity) + .environmentObject(viewModel) } var body: some View { diff --git a/brainwallet/Settings Classes/SettingsLitecoinDetailView.swift b/brainwallet/Settings Classes/SettingsLitecoinDetailView.swift index 31ed69d8..f3ff3b08 100644 --- a/brainwallet/Settings Classes/SettingsLitecoinDetailView.swift +++ b/brainwallet/Settings Classes/SettingsLitecoinDetailView.swift @@ -7,37 +7,68 @@ // import SwiftUI import FirebaseAnalytics +import BrainwalletiOSPrivateGeneralPurpose struct SettingsLitecoinDetailView: View { - - private let syncButtonHeight: CGFloat = 40.0 - private let syncButtonWidth: CGFloat = 140.0 + + @EnvironmentObject + var viewModel: NewMainViewModel @Binding var willSync: Bool - @Binding - var userWantsTrustedNode: Bool + @StateObject + private var storeKitManager = StoreKitManager() + + @State + private var userPrefersMainnetVsTrusted: Bool = UserDefaults.userPrefersUsingTrustedNode - /// Tiered fee-per-kb (economy/regular/luxury), sourced from - /// BWAPIClient.feePerKb(_:) (GET v1/fee-per-kb) via NewMainViewModel.currentFees. let fees: Fees let rate: Rate @State private var feesPerFiat = "" + + @State + private var nodeLabel: String = String(localized: "Set IP Address") + @State private var selectedNetworkFeeIndex = 1 - + + private let syncButtonHeight: CGFloat = 40.0 + private let syncButtonWidth: CGFloat = 180.0 + init(willSync: Binding, - userWantsTrustedNode: Binding, fees: Fees, currentRate: Rate?) { _willSync = willSync - _userWantsTrustedNode = userWantsTrustedNode self.fees = fees self.rate = currentRate ?? Rate(code: "", name: "", rate: 0.0, lastTimestamp: Date()) + + let prefersTrustedNode = UserDefaults.userPrefersUsingTrustedNode + _nodeLabel = State(initialValue: Self.nodeLabel(prefersTrustedNode: prefersTrustedNode)) + } + + private static func nodeLabel(prefersTrustedNode: Bool) -> String { + if prefersTrustedNode { + if let trustedIpAddress = UserDefaults.userTrustedNodeIpAddress, + let trustedPort = UserDefaults.userTrustedNodePort { + return "\(trustedIpAddress):\(trustedPort)" + } else { + return String(localized: "Set IP Address") + } + } else { + return String(localized: "Litecoin Mainnet") + } + } + + private func purchaseTrustedNodeOrEditIPAddress() { + if storeKitManager.isPurchasedNonConsumable(trustedLTCNodeProductId) { + viewModel.shouldShowEditTrustedIPAddress = true + } else { + viewModel.shouldShowTrustedNodeSheet = true + } } /// Litoshis (fee-per-kb) for the currently selected Picker tag (0 = economy, 1 = regular, 2 = luxury). @@ -60,153 +91,163 @@ struct SettingsLitecoinDetailView: View { } var body: some View { - VStack(alignment: .leading, spacing: 20.0) { - - // MARK: - Peer sync mode - VStack(alignment: .leading, spacing: 6.0) { - Divider() - .frame(height: 1) - .overlay(Color.white) - - Text(String(localized: "Peer sync mode")) - .modifier(BWIPSSemiBold(size: 15.0)) - .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(String(localized: "Set from spv peers in the Litecoin mainnet or a trusted peer to sync with the blockchain")) - .modifier(BWIPSRegular(size: 14.0, lineLimit: 2)) - .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - - HStack(alignment: .center) { - VStack(alignment: .leading, spacing: 10.0) { - Text(String(localized: "Toggle mode")) - .modifier(BWIPSSemiBold(size: 14.0)) - .foregroundColor(BrainwalletColor.content) - - Toggle(isOn: $userWantsTrustedNode) { + VStack(alignment: .leading, spacing: 20.0) { + + // MARK: - Peer sync mode + VStack(alignment: .leading, spacing: 6.0) { + Divider() + .frame(height: 1) + .overlay(Color.white) + + Text(String(localized: "Peer sync mode")) + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(String(localized: "Set from spv peers in the Litecoin mainnet or a trusted peer to sync with the blockchain")) + .modifier(BWIPSRegular(size: 14.0, lineLimit: 2)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 10.0) { + Text(String(localized: "Toggle mode")) + .modifier(BWIPSSemiBold(size: 14.0)) + .foregroundColor(BrainwalletColor.content) + + Toggle(isOn: $userPrefersMainnetVsTrusted) { + + } + .labelsHidden() + .toggleStyle(.switch) + .onChange(of: userPrefersMainnetVsTrusted) { _, newValue in + UserDefaults.userPrefersUsingTrustedNode = newValue + nodeLabel = Self.nodeLabel(prefersTrustedNode: newValue) + } } - .labelsHidden() - .toggleStyle(.switch) + + Spacer() + + Button(action: { + purchaseTrustedNodeOrEditIPAddress() + }) { + Text(nodeLabel) + .frame(width: syncButtonWidth, height: syncButtonHeight, + alignment: .center) + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundStyle(.white) + .background( + RoundedRectangle(cornerRadius: syncButtonHeight/2) + .fill(BrainwalletColor.background) + + ) + } + .buttonStyle(.plain) } - - Spacer() - - Button(action: { - - }) { - Text("Set IP Address") - .frame(width: syncButtonWidth, height: syncButtonHeight, - alignment: .center) - .modifier(BWIPSSemiBold(size: 15.0)) - .foregroundStyle(.white) - .background( - RoundedRectangle(cornerRadius: syncButtonHeight/2) - .fill(BrainwalletColor.background) - ) + .padding(.top, 6.0) + } + + // MARK: - Sync duration + VStack(alignment: .leading, spacing: 0.0) { + Divider() + .frame(height: 1) + .overlay(Color.white) + + HStack(alignment: .center) { + GeometryReader { geo in + VStack(alignment: .center, spacing: 12.0) { + Text(String(localized: "Full Blockchain sync:")) + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(String(localized: "If transactions are missing you can run a blockchain sync. This can take >3 hours.")) + .modifier(BWIPSRegular(size: 14.0, lineLimit: 3)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .frame(height: 90.0) + .padding(.top, 2.0) + + Button(action: { + willSync.toggle() + Analytics.logEvent("did_start_resync", + parameters: nil) + }) { + Text("Sync") + .frame(width: syncButtonWidth, height: syncButtonHeight, + alignment: .center) + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundStyle(.white) + .background( + RoundedRectangle(cornerRadius: syncButtonHeight/2) + .fill(BrainwalletColor.background) + ) + } + .frame(height: 44.0) + .buttonStyle(.plain) } - .buttonStyle(.plain) + .padding(.top, 4.0) } - .padding(.top, 6.0) - } - - // MARK: - Sync duration - VStack(alignment: .leading, spacing: 0.0) { - Divider() - .frame(height: 1) - .overlay(Color.white) - - HStack(alignment: .center) { + + // MARK: - Network fee + VStack(alignment: .leading, spacing: 6.0) { + Divider() + .frame(height: 1) + .overlay(Color.white) + + Text(String(localized: "Network Fee (per kb):")) + .modifier(BWIPSSemiBold(size: 15.0)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(String(localized: "A higher value means the transaction completes sooner")) + .modifier(BWIPSRegular(size: 14.0, lineLimit: 2)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .leading) + GeometryReader { geo in - VStack(alignment: .center, spacing: 12.0) { - Text(String(localized: "Full Blockchain sync:")) - .modifier(BWIPSSemiBold(size: 15.0)) + let height = geo.size.height + HStack(alignment: .center, spacing: 8.0) { + Text(String(localized: "Choose preferred fee:")) + .modifier(BWIPSSemiBold(size: 15.0, lineLimit: 1)) .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - Text(String(localized: "If transactions are missing you can run a blockchain sync. This can take >3 hours.")) - .modifier(BWIPSRegular(size: 14.0, lineLimit: 3)) + Picker("", selection: $selectedNetworkFeeIndex) { + Text("\(fees.economy) ł").modifier(BWIPSSemiBold(size: 14.0)).tag(0) + Text("\(fees.regular) ł").modifier(BWIPSSemiBold(size: 14.0)).tag(1) + Text("\(fees.luxury) ł").modifier(BWIPSSemiBold(size: 14.0)).tag(2) + } + .pickerStyle(.wheel) + .frame(width: geo.size.width * 0.4, height: 80.0) + .clipped() + .onAppear { + let fetchedFee = UserDefaults.userSetPreferredNetworkFee + selectedNetworkFeeIndex = closestFeeIndex(toLitoshis: fetchedFee) + updateFeesPerFiat(litoshis: feeInLitoshis(forTag: selectedNetworkFeeIndex)) + } + .onChange(of: selectedNetworkFeeIndex) { _, _ in + switch selectedNetworkFeeIndex { + case 0: updateFeesPerFiat(litoshis: fees.economy) + case 1: updateFeesPerFiat(litoshis: fees.regular) + case 2: updateFeesPerFiat(litoshis: fees.luxury) + default: updateFeesPerFiat(litoshis: fees.luxury) + } + } + + Text(feesPerFiat) + .modifier(BWIPSRegular(size: 14.0, lineLimit: 1)) .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - } + } } - .frame(height: 90.0) + .frame(height: 80.0) .padding(.top, 2.0) - - Button(action: { - willSync.toggle() - Analytics.logEvent("did_start_resync", - parameters: nil) - }) { - Text("Sync") - .frame(width: syncButtonWidth, height: syncButtonHeight, - alignment: .center) - .modifier(BWIPSSemiBold(size: 15.0)) - .foregroundStyle(.white) - .background( - RoundedRectangle(cornerRadius: syncButtonHeight/2) - .fill(BrainwalletColor.background) - ) - } - .frame(height: 44.0) - .buttonStyle(.plain) } - .padding(.top, 4.0) + .padding(.bottom, 12.0) + }.onChange(of: viewModel.shouldShowEditTrustedIPAddress) { _,newValue in + debugPrint("||||shouldShowEditTrustedIPAddress \(newValue)") + nodeLabel = Self.nodeLabel(prefersTrustedNode: true) } - - // MARK: - Network fee - VStack(alignment: .leading, spacing: 6.0) { - Divider() - .frame(height: 1) - .overlay(Color.white) - - Text(String(localized: "Network Fee (per kb):")) - .modifier(BWIPSSemiBold(size: 15.0)) - .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(String(localized: "A higher value means the transaction completes sooner")) - .modifier(BWIPSRegular(size: 14.0, lineLimit: 2)) - .foregroundColor(BrainwalletColor.content) - .frame(maxWidth: .infinity, alignment: .leading) - - GeometryReader { geo in - HStack(alignment: .center, spacing: 8.0) { - Text(String(localized: "Choose preferred fee:")) - .modifier(BWIPSSemiBold(size: 15.0, lineLimit: 1)) - .foregroundColor(BrainwalletColor.content) - - Picker("", selection: $selectedNetworkFeeIndex) { - Text("\(fees.economy) ł").modifier(BWIPSSemiBold(size: 14.0)).tag(0) - Text("\(fees.regular) ł").modifier(BWIPSSemiBold(size: 14.0)).tag(1) - Text("\(fees.luxury) ł").modifier(BWIPSSemiBold(size: 14.0)).tag(2) - } - .pickerStyle(.wheel) - .frame(width: geo.size.width * 0.4, height: 80.0) - .clipped() - .onAppear { - let fetchedFee = UserDefaults.userSetPreferredNetworkFee - selectedNetworkFeeIndex = closestFeeIndex(toLitoshis: fetchedFee) - updateFeesPerFiat(litoshis: feeInLitoshis(forTag: selectedNetworkFeeIndex)) - } - .onChange(of: selectedNetworkFeeIndex) { _, _ in - switch selectedNetworkFeeIndex { - case 0: updateFeesPerFiat(litoshis: fees.economy) - case 1: updateFeesPerFiat(litoshis: fees.regular) - case 2: updateFeesPerFiat(litoshis: fees.luxury) - default: updateFeesPerFiat(litoshis: fees.luxury) - } - } - - Text(feesPerFiat) - .modifier(BWIPSRegular(size: 14.0, lineLimit: 1)) - .foregroundColor(BrainwalletColor.content) - } - } - .frame(height: 80.0) - .padding(.top, 2.0) - } - } - .padding(.bottom, 12.0) } } diff --git a/brainwallet/Settings Classes/SettingsView.swift b/brainwallet/Settings Classes/SettingsView.swift index eda0f535..6b0857bd 100644 --- a/brainwallet/Settings Classes/SettingsView.swift +++ b/brainwallet/Settings Classes/SettingsView.swift @@ -7,72 +7,73 @@ // // import SwiftUI +import BrainwalletiOSPrivateGeneralPurpose struct SettingsView: View { - + @ObservedObject var newMainViewModel: NewMainViewModel - + @Binding var path: [Onboarding] - + @State private var shouldLock: Bool = false - + @State private var didTriggerLock: Bool = false - + @State private var userPrefersDarkMode: Bool = true - + @State private var shouldExpandSecurity: Bool = false - + @State private var shouldExpandCurrency: Bool = false - + @State private var shouldExpandGames: Bool = false - + @State private var shouldExpandBlockchain: Bool = false - + @State private var shouldShowSocialSheet: Bool = false - + @State private var shouldShowSupportSheet: Bool = false - + let footerRowHeight: CGFloat = 55.0 - let squareButtonSize: CGFloat = 55.0 let squareImageSize: CGFloat = 25.0 let themeBorderSize: CGFloat = 44.0 - + private let supportURL = URL(string: "https://brainwallet.co/support")! - + private let socialsURL = URL(string: BrainwalletSocials.linktree)! - + init(viewModel: NewMainViewModel, path: Binding<[Onboarding]>) { self.newMainViewModel = viewModel _path = path - + userPrefersDarkMode = UserDefaults.userPreferredDarkTheme } - + var body: some View { - + NavigationStack { GeometryReader { geometry in let width = geometry.size.width + ZStack { - + if userPrefersDarkMode { BrainwalletColor.surface.edgesIgnoringSafeArea(.all) } else { BrainwalletColor.surface.edgesIgnoringSafeArea(.all) .padding(.trailing, 1.0) } - + HStack { VStack { SettingsExpandingSecurityView(title: String(localized: "Security"), @@ -90,7 +91,8 @@ struct SettingsView: View { .padding(.leading, leadRowPad) .padding(.trailing, trailRowPad) SettingsExpandingBlockchainView(title: String(localized: "Blockchain: Litecoin"), - viewModel: newMainViewModel, shouldExpandBlockchain: $shouldExpandBlockchain) + viewModel: newMainViewModel, + shouldExpandBlockchain: $shouldExpandBlockchain) .frame(maxWidth: .infinity, alignment: .topLeading) .animation(.easeInOut(duration: 0.3), value: shouldExpandBlockchain) .padding(.leading, leadRowPad) @@ -120,13 +122,13 @@ struct SettingsView: View { .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.leading, leadRowPad) .padding(.trailing, trailRowPad) - + SettingsActionLockView(title: String(localized: "Lock"), detailText: "", action: .lock, didTriggerLock: $didTriggerLock) .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.leading, leadRowPad) .padding(.trailing, trailRowPad) - + SettingsLabelView(title: String(localized: "App Version:"), detailText: "\(AppVersion.string)") .frame(maxWidth: .infinity, alignment: .topLeading) @@ -137,7 +139,7 @@ struct SettingsView: View { .frame(width: width * 0.9) .overlay(Color.white) Spacer() - + } .frame(width: width * 0.9) .onChange(of: userPrefersDarkMode) { _,hasDarkPreference in @@ -187,5 +189,34 @@ struct SettingsView: View { .padding(8.0) } } + .sheet(isPresented: $newMainViewModel.shouldShowTrustedNodeSheet) { + TrustedNodeProductsModalView(userPrefersDarkTheme: userPrefersDarkMode) { didPurchase in + Task(priority: .userInitiated) { + if didPurchase { + UserDefaults.userTrustedNodePurchased = didPurchase + newMainViewModel.shouldShowTrustedNodeSheet = false + delay(0.1) { + newMainViewModel.shouldShowEditTrustedIPAddress = true + } + } + } + } + .padding(.bottom, 4) + .presentationDetents([.medium]) + .presentationBackground(BrainwalletColor + .modalBackground(userPrefersDarkTheme: userPrefersDarkMode)) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $newMainViewModel.shouldShowEditTrustedIPAddress) { + SetTrustedNodeIPModalView(shouldPresent: $newMainViewModel.shouldShowEditTrustedIPAddress, + userPrefersDarkTheme: userPrefersDarkMode) + .padding(.bottom, 4) + .presentationDetents([.medium]) + .presentationBackground(BrainwalletColor + .modalBackground(userPrefersDarkTheme: userPrefersDarkMode)) + .presentationDragIndicator(.visible) + } } } + + diff --git a/brainwallet/TrustedNode Classes/SetTrustedNodeIPModalView.swift b/brainwallet/TrustedNode Classes/SetTrustedNodeIPModalView.swift new file mode 100644 index 00000000..447c65e0 --- /dev/null +++ b/brainwallet/TrustedNode Classes/SetTrustedNodeIPModalView.swift @@ -0,0 +1,153 @@ +// +// SetTrustedNodeIPModalView.swift +// brainwallet +// +// Created by Kerry Washington on 24/09/2026. +// Copyright © 2026 Grunt Software, LTD. All rights reserved. +// +import SwiftUI + +struct SetTrustedNodeIPModalView: View { + + @Binding + var shouldPresent: Bool + + var userPrefersDarkTheme: Bool + + @State + private var ipAddress: String = "" + + @State + private var port: String = "" + + private let fieldHeight: CGFloat = 66.0 + private let buttonHeight: CGFloat = 56.0 + + init(shouldPresent: Binding, userPrefersDarkTheme: Bool) { + _shouldPresent = shouldPresent + self.userPrefersDarkTheme = userPrefersDarkTheme + _ipAddress = State(initialValue: UserDefaults.userTrustedNodeIpAddress ?? "") + _port = State(initialValue: UserDefaults.userTrustedNodePort ?? "") + } + + private var canSave: Bool { + Self.isValidIPv4(ipAddress) && Self.isValidPort(port) + } + + var body: some View { + VStack(alignment: .leading, spacing: 22.0) { + + Text(String(localized: "Set trusted Litecoin node / SPV Peer")) + .modifier(BWIPSBold(size: 22.0, lineLimit: 2)) + .foregroundColor(BrainwalletColor.modalPrimaryText(userPrefersDarkTheme: userPrefersDarkTheme)) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(String(localized: "Enter the IPv4 address and port of the Litecoin node your Brainwallet should sync with.")) + .modifier(BWIPSRegular(size: 15.0, lineLimit: 3)) + .foregroundColor(BrainwalletColor.modalSecondaryText(userPrefersDarkTheme: userPrefersDarkTheme)) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .top, spacing: 12.0) { + OutlinedNodeField(label: String(localized: "IP address"), + placeholder: "1.1.1.1", + text: $ipAddress, + keyboardType: .decimalPad, + height: fieldHeight, + userPrefersDarkTheme: userPrefersDarkTheme) + .frame(maxWidth: .infinity) + + OutlinedNodeField(label: String(localized: "Port"), + placeholder: "9333", + text: $port, + keyboardType: .numberPad, + height: fieldHeight, + userPrefersDarkTheme: userPrefersDarkTheme) + .frame(width: 110.0) + } + + Button(action: save) { + Text(String(localized: "Save trusted node")) + .modifier(BWIPSSemiBold(size: 17.0)) + .foregroundColor(.white) + .frame(maxWidth: .infinity, minHeight: buttonHeight) + .background( + Capsule() + .fill(BentoColor.purple2.opacity(canSave ? 1.0 : 0.4)) + ) + } + .buttonStyle(.plain) + .disabled(!canSave) + } + .padding(.horizontal, 24.0) + .padding(.top, 28.0) + .padding(.bottom, 20.0) + .frame(maxWidth: .infinity, alignment: .top) + } + + private func save() { + guard canSave else { return } + UserDefaults.userTrustedNodeIpAddress = ipAddress + UserDefaults.userTrustedNodePort = port + shouldPresent = false + } + + private static func isValidIPv4(_ value: String) -> Bool { + let parts = value.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return false } + return parts.allSatisfy { part in + guard let octet = Int(part), (0 ... 255).contains(octet) else { return false } + return String(octet) == part // rejects leading zeros / stray characters + } + } + + private static func isValidPort(_ value: String) -> Bool { + guard let number = Int(value) else { return false } + return (1 ... 65535).contains(number) + } +} + +/// A bordered text field whose label sits notched into the top border, +/// matching the outlined-field style from the Trusted Node design. +private struct OutlinedNodeField: View { + let label: String + let placeholder: String + + @Binding + var text: String + + let keyboardType: UIKeyboardType + let height: CGFloat + let userPrefersDarkTheme: Bool + + private var borderColor: Color { + BrainwalletColor.modalSecondaryText(userPrefersDarkTheme: userPrefersDarkTheme) + } + + var body: some View { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 12.0) + .stroke(borderColor, lineWidth: 1.0) + + TextField("", + text: $text, + prompt: Text(placeholder) + .foregroundColor(borderColor.opacity(0.6))) + .keyboardType(keyboardType) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .modifier(BWIPSSemiBold(size: 19.0)) + .foregroundColor(BrainwalletColor.modalPrimaryText(userPrefersDarkTheme: userPrefersDarkTheme)) + .padding(.horizontal, 14.0) + .padding(.top, 20.0) + .padding(.bottom, 10.0) + + Text(label) + .modifier(BWIPSRegular(size: 13.0)) + .foregroundColor(borderColor) + .padding(.horizontal, 4.0) + .background(BrainwalletColor.modalBackground(userPrefersDarkTheme: userPrefersDarkTheme)) + .offset(x: 12.0, y: -9.0) + } + .frame(height: height) + } +} From ba207fec73653ee36d23a844cbf8d109350c2376 Mon Sep 17 00:00:00 2001 From: grunt-claude-bot <308083480+grunt-claude-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:46:25 +0100 Subject: [PATCH 4/5] Wire trusted-node preference into BRPeerManager Co-Authored-By: kcw-grunt --- .../Additions/UserDefaults+Additions.swift | 18 ----------- .../Legacy_BW_BRClasses/BRPeerManager.swift | 32 +++++++++++++++++-- .../SettingsLitecoinDetailView.swift | 15 ++++++++- 3 files changed, 43 insertions(+), 22 deletions(-) diff --git a/brainwallet/Additions/UserDefaults+Additions.swift b/brainwallet/Additions/UserDefaults+Additions.swift index 30acdb1a..aff25d4f 100644 --- a/brainwallet/Additions/UserDefaults+Additions.swift +++ b/brainwallet/Additions/UserDefaults+Additions.swift @@ -13,8 +13,6 @@ private let isLTCValueShownKey = "isLTCValueShownKey" private let maxDigitsKey = "SETTINGS_MAX_DIGITS" private let pushTokenKey = "pushTokenKey" private let currentRateKey = "currentRateKey" -private let customNodeIPKey = "customNodeIPKey" -private let customNodePortKey = "customNodePortKey" private let hasPromptedShareDataKey = "hasPromptedShareDataKey" private let didSeeTransactionCorruption = "DidSeeTransactionCorruption" private let hasLoggedInitialSyncDurationKey = "hasLoggedInitialSyncDurationKey" @@ -249,22 +247,6 @@ extension UserDefaults { set { defaults.set(newValue, forKey: currentRateKey) } } - static var customNodeIP: Int? { - get { - guard defaults.object(forKey: customNodeIPKey) != nil else { return nil } - return defaults.integer(forKey: customNodeIPKey) - } - set { defaults.set(newValue, forKey: customNodeIPKey) } - } - - static var customNodePort: Int? { - get { - guard defaults.object(forKey: customNodePortKey) != nil else { return nil } - return defaults.integer(forKey: customNodePortKey) - } - set { defaults.set(newValue, forKey: customNodePortKey) } - } - static var hasPromptedShareData: Bool { get { return defaults.bool(forKey: hasPromptedBiometricsKey) } set { defaults.set(newValue, forKey: hasPromptedBiometricsKey) } diff --git a/brainwallet/Legacy_BW_BRClasses/BRPeerManager.swift b/brainwallet/Legacy_BW_BRClasses/BRPeerManager.swift index 8e0053b6..3fc39853 100644 --- a/brainwallet/Legacy_BW_BRClasses/BRPeerManager.swift +++ b/brainwallet/Legacy_BW_BRClasses/BRPeerManager.swift @@ -1,11 +1,18 @@ import BRCore import Foundation + +enum PeerSyncMode: String { + case random_mainnet + case trusted +} + class BRPeerManager { let cPointer: OpaquePointer let listener: BRPeerManagerListener let mainNetParams = [BRMainNetParams] var falsePositiveRate: Double + var syncMode: PeerSyncMode = .random_mainnet init?(wallet: BRWallet, earliestKeyTime: TimeInterval, @@ -65,12 +72,31 @@ class BRPeerManager { // connect to litecoin peer-to-peer network (also call this whenever networkIsReachable() status changes) func connect() { - if let fixedAddress = UserDefaults.customNodeIP { - setFixedPeer(address: fixedAddress, port: UserDefaults.customNodePort ?? C.standardPort) - } + applyTrustedNodePreference() BRPeerManagerConnect(cPointer) } + private func applyTrustedNodePreference() { + guard UserDefaults.userPrefersUsingTrustedNode, + let ipAddress = UserDefaults.userTrustedNodeIpAddress, + let packedAddress = BRPeerManager.packedIPv4Address(ipAddress) + else { + setFixedPeer(address: 0, port: 0) + syncMode = .random_mainnet + return + } + syncMode = .trusted + let port = UserDefaults.userTrustedNodePort.flatMap(Int.init) ?? C.standardPort + setFixedPeer(address: packedAddress, port: port) + } + + // packs a dotted-quad IPv4 string into the network-byte-order integer setFixedPeer expects + private static func packedIPv4Address(_ ipAddress: String) -> Int? { + var addr = in_addr() + guard inet_pton(AF_INET, ipAddress, &addr) == 1 else { return nil } + return Int(addr.s_addr) + } + // disconnect from litecoin peer-to-peer network func disconnect() { BRPeerManagerDisconnect(cPointer) diff --git a/brainwallet/Settings Classes/SettingsLitecoinDetailView.swift b/brainwallet/Settings Classes/SettingsLitecoinDetailView.swift index f3ff3b08..23fc5a9f 100644 --- a/brainwallet/Settings Classes/SettingsLitecoinDetailView.swift +++ b/brainwallet/Settings Classes/SettingsLitecoinDetailView.swift @@ -71,6 +71,17 @@ struct SettingsLitecoinDetailView: View { } } + /// Re-applies the trusted-node/mainnet preference to the running SPV sync so a toggle + /// flip takes effect immediately rather than on next launch. `setFixedPeer` (called from + /// `connect()`) disconnects any currently-connected peers before pinning/clearing the + /// fixed peer, so this alone is enough to force a clean switch. + private func reconnectPeerManager() { + guard let walletManager = viewModel.walletManager else { return } + DispatchQueue.walletQueue.async { + walletManager.peerManager?.connect() + } + } + /// Litoshis (fee-per-kb) for the currently selected Picker tag (0 = economy, 1 = regular, 2 = luxury). /// Fee tier math lives in `NetworkFeeTier` (FeeManager.swift) so it's unit-testable on its own. private func feeInLitoshis(forTag tag: Int) -> UInt64 { @@ -123,9 +134,11 @@ struct SettingsLitecoinDetailView: View { .onChange(of: userPrefersMainnetVsTrusted) { _, newValue in UserDefaults.userPrefersUsingTrustedNode = newValue nodeLabel = Self.nodeLabel(prefersTrustedNode: newValue) + reconnectPeerManager() } + } - + Spacer() Button(action: { From 84334a7dc2540dad36afb4a73ef8e53773ae775d Mon Sep 17 00:00:00 2001 From: grunt-claude-bot <308083480+grunt-claude-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:48:41 +0100 Subject: [PATCH 5/5] Add peer sync mode icon to sync status Co-Authored-By: kcw-grunt --- .../Settings Tests/SettingsTests.swift | 4 ++-- .../Bento Views/SyncSubBentoView.swift | 10 +++++++++ .../SyncSubBentoViewModel.swift | 5 ++++- .../Assets.xcassets/Trusted/Contents.json | 6 +++++ .../main-network-icon.imageset/Contents.json | 21 ++++++++++++++++++ ...24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.png | Bin 0 -> 596 bytes .../trusted-icon.imageset/Contents.json | 21 ++++++++++++++++++ ...24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.png | Bin 0 -> 613 bytes 8 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 brainwallet/PreLaunchResources/Assets.xcassets/Trusted/Contents.json create mode 100644 brainwallet/PreLaunchResources/Assets.xcassets/Trusted/main-network-icon.imageset/Contents.json create mode 100644 brainwallet/PreLaunchResources/Assets.xcassets/Trusted/main-network-icon.imageset/hub_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.png create mode 100644 brainwallet/PreLaunchResources/Assets.xcassets/Trusted/trusted-icon.imageset/Contents.json create mode 100644 brainwallet/PreLaunchResources/Assets.xcassets/Trusted/trusted-icon.imageset/handshake_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.png diff --git a/BrainwalletUnitTests/Settings Tests/SettingsTests.swift b/BrainwalletUnitTests/Settings Tests/SettingsTests.swift index f3f5f6e5..6db674c9 100644 --- a/BrainwalletUnitTests/Settings Tests/SettingsTests.swift +++ b/BrainwalletUnitTests/Settings Tests/SettingsTests.swift @@ -14,7 +14,7 @@ class SettingsHelpersTests: XCTestCase { // MARK: - Constants Tests func testClosedRowHeight() { - XCTAssertEqual(closedRowHeight, 65.0, "closedRowHeight should be 65.0") + XCTAssertEqual(closedRowHeight, 50.0, "closedRowHeight should be 50.0") } func testToggleRowHeight() { @@ -42,7 +42,7 @@ class SettingsHelpersTests: XCTestCase { } func testExpandArrowSize() { - XCTAssertEqual(expandArrowSize, 20.0, "expandArrowSize should be 20.0") + XCTAssertEqual(expandArrowSize, 16.0, "expandArrowSize should be 16.0") } func testPickerViewHeight() { diff --git a/brainwallet/Bento Views/SyncSubBentoView.swift b/brainwallet/Bento Views/SyncSubBentoView.swift index 941de1ab..86c1eaf8 100644 --- a/brainwallet/Bento Views/SyncSubBentoView.swift +++ b/brainwallet/Bento Views/SyncSubBentoView.swift @@ -29,6 +29,7 @@ struct SyncSubBentoView: View { private let progressBarHeight: CGFloat = 14.0 private let progressIconSize: CGFloat = 13.0 + private let syncModeIconSize: CGFloat = 10.0 init(viewModel: SyncSubBentoViewModel) { self.viewModel = viewModel @@ -40,12 +41,21 @@ struct SyncSubBentoView: View { Spacer() HStack { Spacer() + Image(viewModel.peerSyncMode == .random_mainnet ? + "main-network-icon" :"trusted-icon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: syncModeIconSize, + height: syncModeIconSize, + alignment: .bottomLeading) + .padding(.leading, 12) Text(viewModel.syncStateMessage) .modifier(BWIPSSemiBold(size: 11.0)) .frame(alignment: .trailing) .foregroundColor(.white) } + .frame(width: 160) .padding(.bottom, 2) HStack { Spacer() diff --git a/brainwallet/New Main Classes/SyncSubBentoViewModel.swift b/brainwallet/New Main Classes/SyncSubBentoViewModel.swift index cf6cad5c..37492cdc 100644 --- a/brainwallet/New Main Classes/SyncSubBentoViewModel.swift +++ b/brainwallet/New Main Classes/SyncSubBentoViewModel.swift @@ -45,6 +45,9 @@ class SyncSubBentoViewModel: ObservableObject, Subscriber { @Published var syncStateMessage = "" + + @Published + var peerSyncMode: PeerSyncMode = .random_mainnet @Published var syncStateMessageColor: Color = BrainwalletColor.content @@ -204,7 +207,7 @@ class SyncSubBentoViewModel: ObservableObject, Subscriber { if reduxState.walletState.syncState == .syncing { self.isSyncing = true - + self.peerSyncMode = peerManager.syncMode self.currentBlockHeightString = "\(peerManager.lastBlockHeight)" self.currentBlockHeight = peerManager.lastBlockHeight diff --git a/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/Contents.json b/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/main-network-icon.imageset/Contents.json b/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/main-network-icon.imageset/Contents.json new file mode 100644 index 00000000..eb88a52e --- /dev/null +++ b/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/main-network-icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "hub_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/main-network-icon.imageset/hub_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.png b/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/main-network-icon.imageset/hub_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.png new file mode 100644 index 0000000000000000000000000000000000000000..0275158d169edbaf70f4f72bf8c98894dda00102 GIT binary patch literal 596 zcmV-a0;~OrP)>ML+3M&(G8Zt_gqP&b$h|331D5txU7Ar7;{RO2$)RR_L$F0h5;v+oy2XE& zI0rr8aR-lu=|AHGs$@=oV4{nda>VyhXie4|9vcLVZGS;eBB%^x$AnI=X`V?+yY-bc;$FaWVd2 zx<52C{Z`_{L?{P(8v$l&fPWwWbF#O{4_;t6XMK-!s4!8Ro7Dyd?3Q)?g2Fv|*YiW? zv07Wyo(8{}?faqPhWG;;-_UUznFVvufC=>vh*PB;pcA!LX3rb+wAoD`4F+@wtd zPy%xM37Y@#nRlGmJVrUJg*FYy1Uah{z*YPc00030{~pcv i-v9sr21!IgR09AA|NJo|nc3$60000j3xw literal 0 HcmV?d00001 diff --git a/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/trusted-icon.imageset/Contents.json b/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/trusted-icon.imageset/Contents.json new file mode 100644 index 00000000..1039e4cf --- /dev/null +++ b/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/trusted-icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "handshake_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/trusted-icon.imageset/handshake_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.png b/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/trusted-icon.imageset/handshake_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.png new file mode 100644 index 0000000000000000000000000000000000000000..5de0fb3fcc5456937c702e224e895ce0c5c54297 GIT binary patch literal 613 zcmV-r0-F7aP){p{?9lERJ)um973IovncE2NM3Gt!(lC!=W;aa)W<2T9e5h z0l|V_4sq)h5AzFDIroL$;!a_nLf$M%M?g>uegVQ`W=DLCh%>b0YfXq*)tvl2l97PL zDfp9#`0c%G->{3sa~NB7h$IOHkZOxNZ^7#jze6x3ksNo#5=jz7M1d@U|0G5!lC=i< z1s!EVl9!|iGKhe=op12}AfP}cv1Pd8x35--tuwjO-x25-+Eo>8@ZIBkAkhP_+k`vr z@coJ-PZ8V_cLtEG9XS5~=p}1g0H4qjb6DzyIIA6