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"> - - 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/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/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/Private/general-purpose b/Private/general-purpose index 378c2e0e..bf0f6969 160000 --- a/Private/general-purpose +++ b/Private/general-purpose @@ -1 +1 @@ -Subproject commit 378c2e0ee89c4f91fc2a9526fd49b56905251651 +Subproject commit bf0f6969c02c266efefa76fe50500e1195991639 diff --git a/brainwallet.xcodeproj/project.pbxproj b/brainwallet.xcodeproj/project.pbxproj index 0f81e682..05adddcd 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 */; }; @@ -225,6 +224,7 @@ 24FBB86A2E035E10002B5F84 /* SettingsHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24FBB8692E035E07002B5F84 /* SettingsHostingController.swift */; }; 24FF3E6A2E898D4000FF43C5 /* BrainwalletiOSPrivateGeneralPurpose.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 246F97E72E846218001923A6 /* BrainwalletiOSPrivateGeneralPurpose.framework */; platformFilter = ios; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 759DA0BE1DAC36A3008CC49B /* libBRCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 755CD9D11DAA197C0075898E /* libBRCore.a */; settings = {ATTRIBUTES = (Required, ); }; }; + 89B92973AA76D3E95DC98045 /* SetTrustedNodeIPModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907FE3B2FCD7C7D94FA7D2A7 /* SetTrustedNodeIPModalView.swift */; }; 9ED4226AC1AA476BB099F35A /* BalanceBentoReviewPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1465DCBE495A8993C1FD /* BalanceBentoReviewPolicy.swift */; }; C30D099E2FC5AB720083EDE2 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C30D099D2FC5AB720083EDE2 /* GoogleService-Info.plist */; }; C312D2D02D7DC27700BB97A4 /* ReachabilityMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D2912D7DC27700BB97A4 /* ReachabilityMonitor.swift */; }; @@ -376,6 +376,7 @@ C35C122A293D48340009022D /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = C35C1229293D48340009022D /* KeychainAccess */; }; C38F57252FD490430005538A /* boingspringmouthharp042013.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = C38F57242FD490430005538A /* boingspringmouthharp042013.mp3 */; }; C395E2B83013770A00CF5F5B /* EnterPinForSeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C395E2B2301376FE00CF5F5B /* EnterPinForSeedView.swift */; }; + C39859383069234400E99439 /* bw-iap-config-v2.storekit in Resources */ = {isa = PBXBuildFile; fileRef = C39859373069234400E99439 /* bw-iap-config-v2.storekit */; }; C3B0BBC32FF6DB11000D5DB8 /* AlertToast in Frameworks */ = {isa = PBXBuildFile; productRef = C3B0BBC22FF6DB11000D5DB8 /* AlertToast */; }; C3BC6CBD303331FB00A04919 /* UIApplication+Additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BC6CBC303331D800A04919 /* UIApplication+Additions.swift */; }; C3C95F082FA13DEF002E2BEE /* SignupAskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C95F072FA13DEF002E2BEE /* SignupAskView.swift */; }; @@ -421,20 +422,6 @@ remoteGlobalIDString = 2425ACB32E4BD610007185C7; remoteInfo = "private-general-purpose"; }; - 246F97E22E846218001923A6 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 246F97832E846218001923A6 /* private-general-purpose.xcodeproj */; - proxyType = 2; - remoteGlobalIDString = 2425ACC02E4BD612007185C7; - remoteInfo = "private-general-purposeTests"; - }; - 246F97E42E846218001923A6 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 246F97832E846218001923A6 /* private-general-purpose.xcodeproj */; - proxyType = 2; - remoteGlobalIDString = 2425ACCA2E4BD612007185C7; - remoteInfo = "private-general-purposeUITests"; - }; 246F97E62E846218001923A6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 246F97832E846218001923A6 /* private-general-purpose.xcodeproj */; @@ -793,7 +780,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 = ""; }; @@ -806,6 +792,7 @@ 75A2A7F21DA5935F00A983D8 /* Messages.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Messages.framework; path = System/Library/Frameworks/Messages.framework; sourceTree = SDKROOT; }; 75A2A8091DA5936F00A983D8 /* NotificationCenter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NotificationCenter.framework; path = System/Library/Frameworks/NotificationCenter.framework; sourceTree = SDKROOT; }; 75FEFD1B1DAED56E00203D3A /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; + 907FE3B2FCD7C7D94FA7D2A7 /* SetTrustedNodeIPModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetTrustedNodeIPModalView.swift; sourceTree = ""; }; C30D099D2FC5AB720083EDE2 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; C312D06B2D7DC27700BB97A4 /* ApplicationController+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ApplicationController+Extension.swift"; sourceTree = ""; }; C312D06C2D7DC27700BB97A4 /* Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Async.swift; sourceTree = ""; }; @@ -958,6 +945,7 @@ C353E3B42FAE9F9000BFFB30 /* ShopBentoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopBentoViewModel.swift; sourceTree = ""; }; C38F57242FD490430005538A /* boingspringmouthharp042013.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = boingspringmouthharp042013.mp3; sourceTree = ""; }; C395E2B2301376FE00CF5F5B /* EnterPinForSeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterPinForSeedView.swift; sourceTree = ""; }; + C39859373069234400E99439 /* bw-iap-config-v2.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = "bw-iap-config-v2.storekit"; sourceTree = ""; }; C3BC6CBC303331D800A04919 /* UIApplication+Additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Additions.swift"; sourceTree = ""; }; C3C95F072FA13DEF002E2BEE /* SignupAskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupAskView.swift; sourceTree = ""; }; C3CE34602FFD8D1F004A2ECA /* LilitaOne.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = LilitaOne.ttf; sourceTree = ""; }; @@ -1323,8 +1311,6 @@ isa = PBXGroup; children = ( 246F97E12E846218001923A6 /* private-general-purpose.app */, - 246F97E32E846218001923A6 /* private-general-purposeTests.xctest */, - 246F97E52E846218001923A6 /* private-general-purposeUITests.xctest */, 246F97E72E846218001923A6 /* BrainwalletiOSPrivateGeneralPurpose.framework */, 246F97E92E846218001923A6 /* BrainwalletiOSPrivateGeneralPurposeTests.xctest */, ); @@ -1725,7 +1711,6 @@ 24DB05EB2E06B5BB00C57FE9 /* SettingsExpandingCurrencyView.swift */, 24DB05E92E06B49B00C57FE9 /* SettingsExpandingGamesView.swift */, 24DB05E72E06B44700C57FE9 /* SettingsExpandingBlockchainView.swift */, - 24F5BD492E0442D00010D340 /* SettingsFooterView.swift */, ); path = "Settings Classes"; sourceTree = ""; @@ -1768,6 +1753,7 @@ 24D7E9FC2E336CE100A9AB68 /* PreLaunchResources */ = { isa = PBXGroup; children = ( + C39859373069234400E99439 /* bw-iap-config-v2.storekit */, 24D7EA0D2E336F9100A9AB68 /* service-data.plist */, 24D7E9FD2E336D0800A9AB68 /* general.css */, 24D7E9FE2E336D0800A9AB68 /* moon_pay_buy_status.txt */, @@ -1966,6 +1952,7 @@ C312D2CD2D7DC27700BB97A4 /* brainwallet */ = { isa = PBXGroup; children = ( + C35BD2C63065C1C300B4EE55 /* TrustedNode Classes */, C31F416D2FF6918E00D9DA3D /* Social Network Classes */, C3C95F702FA1498E002E2BEE /* Signup Notification Classes */, 246C3F4F2F2430DF009BC881 /* Tutorial Classes */, @@ -2113,6 +2100,14 @@ path = "Social Network Classes"; sourceTree = ""; }; + C35BD2C63065C1C300B4EE55 /* TrustedNode Classes */ = { + isa = PBXGroup; + children = ( + 907FE3B2FCD7C7D94FA7D2A7 /* SetTrustedNodeIPModalView.swift */, + ); + path = "TrustedNode Classes"; + sourceTree = ""; + }; C3C95F702FA1498E002E2BEE /* Signup Notification Classes */ = { isa = PBXGroup; children = ( @@ -2374,20 +2369,6 @@ remoteRef = 246F97E02E846218001923A6 /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; - 246F97E32E846218001923A6 /* private-general-purposeTests.xctest */ = { - isa = PBXReferenceProxy; - fileType = wrapper.cfbundle; - path = "private-general-purposeTests.xctest"; - remoteRef = 246F97E22E846218001923A6 /* PBXContainerItemProxy */; - sourceTree = BUILT_PRODUCTS_DIR; - }; - 246F97E52E846218001923A6 /* private-general-purposeUITests.xctest */ = { - isa = PBXReferenceProxy; - fileType = wrapper.cfbundle; - path = "private-general-purposeUITests.xctest"; - remoteRef = 246F97E42E846218001923A6 /* PBXContainerItemProxy */; - sourceTree = BUILT_PRODUCTS_DIR; - }; 246F97E72E846218001923A6 /* BrainwalletiOSPrivateGeneralPurpose.framework */ = { isa = PBXReferenceProxy; fileType = wrapper.framework; @@ -2443,6 +2424,7 @@ 24AFEEF22F3152CE00427D02 /* IBMPlexSans-Regular.ttf in Resources */, 24D7EA082E336D0800A9AB68 /* moon_pay_buy_status.txt in Resources */, 24D7EA092E336D0800A9AB68 /* RELEASE_NOTES.md in Resources */, + C39859383069234400E99439 /* bw-iap-config-v2.storekit in Resources */, 24D7EA0A2E336D0800A9AB68 /* general.css in Resources */, 24D7EA0B2E336D0800A9AB68 /* CHANGELOG.md in Resources */, 24D7EA0E2E336F9100A9AB68 /* service-data.plist in Resources */, @@ -2806,7 +2788,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 */, @@ -2861,6 +2842,7 @@ C312D3FD2D7DC27700BB97A4 /* LocaleChangeViewModel.swift in Sources */, C312D3FE2D7DC27700BB97A4 /* ReduxState.swift in Sources */, 24A0ACAB2E0C812C0077E695 /* SettingsLitecoinDetailView.swift in Sources */, + 89B92973AA76D3E95DC98045 /* SetTrustedNodeIPModalView.swift in Sources */, 24D7AE562DCFF6870078252E /* NewConfirmView.swift in Sources */, C312D4022D7DC27700BB97A4 /* DrawableCircle.swift in Sources */, 24B0653B2DC6433800B5E000 /* SimpleHeaderView.swift in Sources */, diff --git a/brainwallet/Additions/UserDefaults+Additions.swift b/brainwallet/Additions/UserDefaults+Additions.swift index 810f9b66..aff25d4f 100644 --- a/brainwallet/Additions/UserDefaults+Additions.swift +++ b/brainwallet/Additions/UserDefaults+Additions.swift @@ -1,4 +1,5 @@ import Foundation +import KeychainAccess private let defaults = UserDefaults.standard private let isBiometricsEnabledKey = "isbiometricsenabled" @@ -12,20 +13,29 @@ 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" private let foregroundSyncDurationSecondsKey = "foregroundSyncDurationSecondsKey" private let pendingNotificationBadgeCountKey = "pendingNotificationBadgeCountKey" +private let trustedNodeKeychain = Keychain(service: "brainwallet.trustednode") +private let userTrustedNodeIpAddressKey = "userTrustedNodeIpAddress" +private let userTrustedNodePortKey = "userTrustedNodePort" +private let userPurchasedTrustedNodeKey = "userPurchasedTrustedNode" + +/// StoreKit product id for the Trusted LTC Node in-app purchase. Must match +/// "Trusted LTC Node Feature" in BWProductsList.plist (ios-private-general-purpose). +let trustedLTCNodeProductId = "com.gruntsoftware.brainwallet.trusted_ltc_node_1" + let timeSinceLastExitKey = "TimeSinceLastExit" let shouldRequireLoginTimeoutKey = "ShouldRequireLoginTimeoutKey" let numberOfBrainwalletLaunches = "NumberOfBrainwalletLaunches" let appHasRequestedReviewKey = "appHasRequestedReviewKey" let userDidPreferDarkModeKey = "UserDidPreferDarkMode" let userCurrentLocaleMPApprovedKey = "UserCurrentLocaleMPApproved" +let userSetPreferredNetworkFeeKey = "UserSetPreferredNetworkFeeKey" +let userPrefersUsingTrustedNodeKey = "UserPrefersUsingTrustedNodeKey" public extension NSNotification.Name { static let walletBalanceChangedNotification = NSNotification.Name("WalletBalanceChanged") @@ -48,7 +58,58 @@ public extension NSNotification.Name { } extension UserDefaults { + + // MARK: - Trusted Node + static var userTrustedNodeIpAddress: String? { + get { trustedNodeKeychain[userTrustedNodeIpAddressKey] } + set { trustedNodeKeychain[userTrustedNodeIpAddressKey] = newValue } + } + + static var userTrustedNodePort: String? { + get { trustedNodeKeychain[userTrustedNodePortKey] } + set { trustedNodeKeychain[userTrustedNodePortKey] = newValue } + } + + static var userTrustedNodePurchased: Bool? { + get { + guard let value = trustedNodeKeychain[userPurchasedTrustedNodeKey] else { return nil } + return value == "true" + } + set { + guard let newValue else { + trustedNodeKeychain[userPurchasedTrustedNodeKey] = nil + return + } + trustedNodeKeychain[userPurchasedTrustedNodeKey] = newValue ? "true" : "false" + } + } + + static func wipeTrustedNodeKeychain() throws { + try trustedNodeKeychain.removeAll() + } + static var userPrefersUsingTrustedNode: Bool { + get { + guard defaults.object(forKey: userPrefersUsingTrustedNodeKey) != nil + else { + return true + } + return defaults.bool(forKey: userPrefersUsingTrustedNodeKey) + } + set { defaults.set(newValue, forKey: userPrefersUsingTrustedNodeKey) } + } + + 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 @@ -186,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/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/Bento Views/Transaction Bentos/TransactionDetailBentoView.swift b/brainwallet/Bento Views/Transaction Bentos/TransactionDetailBentoView.swift index 50b5ced5..5263b68b 100644 --- a/brainwallet/Bento Views/Transaction Bentos/TransactionDetailBentoView.swift +++ b/brainwallet/Bento Views/Transaction Bentos/TransactionDetailBentoView.swift @@ -73,8 +73,6 @@ struct TransactionDetailBentoView: View { @State private var qrImage = UIImage() - private var productsTitle = "Products" - init(viewModel: NewMainViewModel, userPrefersDarkTheme: Binding) { _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/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/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/Localizations/Localizable.xcstrings b/brainwallet/Localizations/Localizable.xcstrings index 7c93f511..7552c108 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" : { @@ -10807,6 +10816,9 @@ } } } + }, + "Enter the IPv4 address and port of the Litecoin node your Brainwallet should sync with." : { + }, "Enter your current PIN." : { "extractionState" : "stale", @@ -11982,6 +11994,9 @@ } } } + }, + "Failed to wipe wallet" : { + }, "Failed to wipe wallet." : { "localizations" : { @@ -12643,6 +12658,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 +12787,9 @@ } } } + }, + "Full Blockchain sync:" : { + }, "Game & Sync" : { "localizations" : { @@ -14995,6 +15014,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" : { @@ -15255,6 +15277,9 @@ } } } + }, + "IP address" : { + }, "It's your emojis you use to remember your 12 seed words." : { "localizations" : { @@ -16298,6 +16323,9 @@ } } } + }, + "Litecoin Mainnet" : { + }, "Loading Wallet" : { "extractionState" : "stale", @@ -17991,6 +18019,9 @@ } } } + }, + "Network Fee (per kb):" : { + }, "Network fees" : { "localizations" : { @@ -19688,6 +19719,9 @@ } } } + }, + "Peer sync mode" : { + }, "Pick 3 Emojis" : { "localizations" : { @@ -21254,6 +21288,9 @@ } } } + }, + "Please try again." : { + }, "Please write this down" : { "localizations" : { @@ -21384,6 +21421,9 @@ } } } + }, + "Port" : { + }, "POWERED BY MOONPAY" : { "localizations" : { @@ -24120,6 +24160,9 @@ } } } + }, + "Save trusted node" : { + }, "Save your 12 words" : { "localizations" : { @@ -26206,6 +26249,12 @@ } } } + }, + "Set from spv peers in the Litecoin mainnet or a trusted peer to sync with the blockchain" : { + + }, + "Set IP Address" : { + }, "Set PIN" : { "localizations" : { @@ -26336,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.", @@ -28294,6 +28346,7 @@ } }, "Sync to the Litecoin Blockchain" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -28554,6 +28607,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 +31611,7 @@ } }, "This process can take 5 - 45 mins depending on your connectivty to the Litecoin network." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -32466,6 +32521,9 @@ } } } + }, + "Toggle mode" : { + }, "TOP SECRET" : { "extractionState" : "stale", @@ -35464,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 64314729..fd5ebe59 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) @@ -214,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 @@ -253,6 +265,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/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 00000000..0275158d Binary files /dev/null and b/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/main-network-icon.imageset/hub_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.png differ 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 00000000..5de0fb3f Binary files /dev/null and b/brainwallet/PreLaunchResources/Assets.xcassets/Trusted/trusted-icon.imageset/handshake_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.png differ 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/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..7e8c347b 100644 --- a/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift +++ b/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift @@ -19,73 +19,79 @@ struct SettingsExpandingBlockchainView: View { @State private var willSync: Bool = false + private var title: String - init(title: String, viewModel: NewMainViewModel, shouldExpandBlockchain: Binding ) { + /// Height of the detail panel when expanded; total expanded row = closedRowHeight + this. + private let detailExpandedHeight: CGFloat = 340.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, + fees: viewModel.currentFees, + currentRate: viewModel.exchangeRate) + .frame(maxWidth: .infinity) + .environmentObject(viewModel) + } - 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) + .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.") + }) } } 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..23fc5a9f 100644 --- a/brainwallet/Settings Classes/SettingsLitecoinDetailView.swift +++ b/brainwallet/Settings Classes/SettingsLitecoinDetailView.swift @@ -7,124 +7,260 @@ // import SwiftUI import FirebaseAnalytics +import BrainwalletiOSPrivateGeneralPurpose 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.") + + @EnvironmentObject + var viewModel: NewMainViewModel @Binding var willSync: Bool - init(willSync: Binding) { + @StateObject + private var storeKitManager = StoreKitManager() + + @State + private var userPrefersMainnetVsTrusted: Bool = UserDefaults.userPrefersUsingTrustedNode + + 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, + fees: Fees, + currentRate: Rate?) { _willSync = willSync - } + self.fees = fees + self.rate = currentRate ?? Rate(code: "", name: "", rate: 0.0, lastTimestamp: Date()) - var body: some View { - NavigationStack { - GeometryReader { geometry in - let width = geometry.size.width - let height = geometry.size.height + let prefersTrustedNode = UserDefaults.userPrefersUsingTrustedNode + _nodeLabel = State(initialValue: Self.nodeLabel(prefersTrustedNode: prefersTrustedNode)) + } - ZStack { - List { - HStack { - Text(String(localized: "Sync to the Litecoin Blockchain")) - .modifier(BWIPSSemiBold(size: 19.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) + 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 + } + } - HStack { - Text(litecoinSyncDetailA) - .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) + /// 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() + } + } - HStack { + /// 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) + } - 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) + /// 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) + } - HStack { + /// 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 + } - Text(litecoinSyncDetailC) - .modifier(BWIPSLight(size: 18.0)) - .multilineTextAlignment(.leading) + 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) - .frame(maxWidth: .infinity, alignment: .leading) - Spacer() - } - .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) - ) - } + + Toggle(isOn: $userPrefersMainnetVsTrusted) { + + } + .labelsHidden() + .toggleStyle(.switch) + .onChange(of: userPrefersMainnetVsTrusted) { _, newValue in + UserDefaults.userPrefersUsingTrustedNode = newValue + nodeLabel = Self.nodeLabel(prefersTrustedNode: newValue) + reconnectPeerManager() } + } - .frame(height: height * 0.2) - .background(BrainwalletColor.background) - .listRowBackground(BrainwalletColor.background) - .listRowSeparator(.hidden) - .padding(.bottom, 32.0) + 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) + } + .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) } - .background(BrainwalletColor.background) - .listRowBackground(BrainwalletColor.background) - .listRowSeparator(.hidden) + .padding(.top, 4.0) } + + // 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 + 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) + + 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) + }.onChange(of: viewModel.shouldShowEditTrustedIPAddress) { _,newValue in + debugPrint("||||shouldShowEditTrustedIPAddress \(newValue)") + nodeLabel = Self.nodeLabel(prefersTrustedNode: true) } - } } } diff --git a/brainwallet/Settings Classes/SettingsView.swift b/brainwallet/Settings Classes/SettingsView.swift index 0aa655b3..6b0857bd 100644 --- a/brainwallet/Settings Classes/SettingsView.swift +++ b/brainwallet/Settings Classes/SettingsView.swift @@ -7,157 +7,139 @@ // // 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 - - @State - private var tempRowHeight: CGFloat = closedRowHeight - + 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 { - 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() } - .listStyle(.plain) - .scrollIndicators(.hidden) - .buttonStyle(PlainButtonStyle()) - SettingsFooterView() - .frame(height: footerRowHeight * 0.4, alignment: .bottom) - .padding(.bottom, 1.0) - .padding(.top, 24.0) - + SettingsLabelView(title: String(localized: "Support"), + detailText: "support.brainwallet.co") + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.leading, leadRowPad) + .padding(.trailing, trailRowPad) + .onTapGesture { + shouldShowSupportSheet.toggle() + } + 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) .onChange(of: userPrefersDarkMode) { _,hasDarkPreference in @@ -180,9 +162,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() } })) @@ -206,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) + } +}