diff --git a/.gitignore b/.gitignore index 3071207ee..8c8b51070 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ ## Litewallet related - litewallet/partner-keys.plist litewallet/GoogleService-Info.plist diff --git a/litewallet/Assets.xcassets/TabBar/Contents.json b/litewallet/Assets.xcassets/TabBar/Contents.json index da4a164c9..2d92bd53f 100644 --- a/litewallet/Assets.xcassets/TabBar/Contents.json +++ b/litewallet/Assets.xcassets/TabBar/Contents.json @@ -3,4 +3,4 @@ "version" : 1, "author" : "xcode" } -} \ No newline at end of file +} diff --git a/litewallet/Assets.xcassets/TabBar/receiveArrowIcon.imageset/Contents.json b/litewallet/Assets.xcassets/TabBar/receiveArrowIcon.imageset/Contents.json new file mode 100644 index 000000000..417af11fe --- /dev/null +++ b/litewallet/Assets.xcassets/TabBar/receiveArrowIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LW_RecieveV2.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/litewallet/Assets.xcassets/TabBar/receiveArrowIcon.imageset/LW_RecieveV2.png b/litewallet/Assets.xcassets/TabBar/receiveArrowIcon.imageset/LW_RecieveV2.png new file mode 100644 index 000000000..776206531 Binary files /dev/null and b/litewallet/Assets.xcassets/TabBar/receiveArrowIcon.imageset/LW_RecieveV2.png differ diff --git a/litewallet/BRAPIClient+Features.swift b/litewallet/BRAPIClient+Features.swift index 1fe0e9eff..f0384b7e4 100644 --- a/litewallet/BRAPIClient+Features.swift +++ b/litewallet/BRAPIClient+Features.swift @@ -33,10 +33,4 @@ extension BRAPIClient { } }.resume() } - - static func featureEnabled(_ flag: BRFeatureFlags) -> Bool { - if E.isDebug || E.isTestFlight { return true } - let defaults = UserDefaults.standard - return defaults.bool(forKey: BRAPIClient.defaultsKeyForFeatureFlag(flag.description)) - } } diff --git a/litewallet/BRCore.swift b/litewallet/BRCore.swift index b3e105978..963128ae8 100644 --- a/litewallet/BRCore.swift +++ b/litewallet/BRCore.swift @@ -4,9 +4,6 @@ import Foundation typealias BRTxRef = UnsafeMutablePointer typealias BRBlockRef = UnsafeMutablePointer -// Used for convert byte array to LTC Address -let characterLengthConstant = 75 - /// BRPeerManagerError: Error enum BRPeerManagerError: Error { case posixError(errorCode: Int32, description: String) diff --git a/litewallet/Constants/Constants+Events.swift b/litewallet/Constants/Constants+Events.swift index b514b7e3b..e12849d1e 100644 --- a/litewallet/Constants/Constants+Events.swift +++ b/litewallet/Constants/Constants+Events.swift @@ -206,21 +206,6 @@ enum CustomEvent: String { /// User tapped balance case _20200207_DTHB = "did_tap_header_balance" - /// Ternio API Wallet details failure - case _20210405_TAWDF = "ternio_api_wallet_details_failure" - - /// Ternio API Authenticate Enable 2FA change - case _20210804_TAA2FAC = "ternio_API_auth_2FA_change" - - /// Ternio API Wallet details success - case _20210804_TAWDS = "ternio_API_wallet_details_success" - - /// Ternio API Login - case _20210804_TAULI = "ternio_API_user_log_in" - - /// Ternio API Logout - case _20210804_TAULO = "ternio_API_user_log_out" - /// Heartbeat check If event even happens case _20210427_HCIEEH = "heartbeat_check_if_event_even_happens" @@ -235,4 +220,7 @@ enum CustomEvent: String { /// User signup case _20240101_US = "user_signup" + + /// Transactions info + case _20240214_TI = "transactions_info" } diff --git a/litewallet/Constants/Functions.swift b/litewallet/Constants/Functions.swift index 7805bf290..99e2d5296 100644 --- a/litewallet/Constants/Functions.swift +++ b/litewallet/Constants/Functions.swift @@ -39,24 +39,16 @@ func strongify(_ context: Context?, closure: @esc } } -/// Description: 1701029422 +/// Description: 1707828867 func tieredOpsFee(amount: UInt64) -> UInt64 { switch amount { - case 0 ..< 1_398_000: - return 69900 - case 1_398_000 ..< 6_991_000: - return 111_910 - case 6_991_000 ..< 27_965_000: - return 279_700 - case 27_965_000 ..< 139_820_000: - return 699_540 - case 139_820_000 ..< 279_653_600: - return 1_049_300 - case 279_653_600 ..< 699_220_000: - return 1_398_800 - case 699_220_000 ..< 1_398_440_000: - return 2_797_600 + case 0 ..< 250_000_000: + return 350_000 + case 250_000_000 ..< 1_000_000_000: + return 1_500_000 + case _ where amount > 1_000_000_000: + return 3_500_000 default: - return 2_797_600 + return 3_500_000 } } diff --git a/litewallet/Constants/Strings.swift b/litewallet/Constants/Strings.swift index 36ce677a1..82a06f58f 100644 --- a/litewallet/Constants/Strings.swift +++ b/litewallet/Constants/Strings.swift @@ -66,10 +66,6 @@ enum S { } } - enum Conjuction { - static let asOf = Localization(key: "Conjunction.asOf", value: "as of", comment: "as of a time or date") - } - // MARK: - Generic Button labels enum Button { @@ -115,7 +111,8 @@ enum S { static let cameraUnavailableTitle = Localization(key: "Send.cameraUnavailableTitle", value: "Litewallet is not allowed to access the camera", comment: "Camera not allowed alert title") static let cameraUnavailableMessage = Localization(key: "Send.cameraunavailableMessage", value: "Go to Settings to allow camera access.", comment: "Camera not allowed message") static let balance = Localization(key: "Send.balance", value: "Balance: %1$@", comment: "Balance: $4.00") - static let fee = Localization(key: "Send.fees", value: "Fees: %1$@", comment: "Fees: $0.10") + static let networkFee = Localization(key: "Send.networkFee", value: "Network", comment: "Network") + static let serviceFee = Localization(key: "Send.serviceFee", value: "Service", comment: "Service") static let feeBlank = Localization(key: "Send.feeBlank", value: "Fees:", comment: "Fees: ") static let bareFee = Localization(key: "Send.fee", value: "Fee: %1$@", comment: "Fee: $0.01") static let containsAddress = Localization(key: "Send.containsAddress", value: "The destination is your own address. You cannot send to yourself.", comment: "Warning when sending to self.") @@ -207,7 +204,7 @@ enum S { static let complete = Localization(key: "Transaction.complete", value: "Complete", comment: "Transaction complete label") static let waiting = Localization(key: "Transaction.waiting", value: "Waiting to be confirmed. Some merchants require confirmation to complete a transaction. Estimated time: 1-2 hours.", comment: "Waiting to be confirmed string") static let starting = Localization(key: "Transaction.starting", value: "Starting balance: %1$@", comment: "eg. Starting balance: $50.00") - static let fee = Localization(key: "Transaction.fee", value: "(%1$@ fee)", comment: "(b600 fee)") + static let fee = Localization(key: "Transaction.fee", value: "(%1$@ total fees)", comment: "(b600 fee)") static let ending = Localization(key: "Transaction.ending", value: "Ending balance: %1$@", comment: "eg. Ending balance: $50.00") static let exchangeOnDaySent = Localization(key: "Transaction.exchangeOnDaySent", value: "Exchange rate when sent:", comment: "Exchange rate on date header") static let exchangeOnDayReceived = Localization(key: "Transaction.exchangeOnDayReceived", value: "Exchange rate when received:", comment: "Exchange rate on date header") diff --git a/litewallet/Environment.swift b/litewallet/Environment.swift index 8541978fa..069cd7708 100644 --- a/litewallet/Environment.swift +++ b/litewallet/Environment.swift @@ -137,18 +137,6 @@ struct E { return (UIScreen.main.bounds.size.height == 812.0) } - static var isIPhone8Plus: Bool { - return (UIScreen.main.bounds.size.height == 736.0) - } - - static var isIPhoneXsMax: Bool { - return (UIScreen.main.bounds.size.height == 812.0) - } - - static var isIPad: Bool { - return (UIDevice.current.userInterfaceIdiom == .pad) - } - static let is32Bit: Bool = { MemoryLayout.size == MemoryLayout.size }() diff --git a/litewallet/Extensions/SafariServices+Extension.swift b/litewallet/Extensions/SafariServices+Extension.swift index 06edd45e3..19605b897 100644 --- a/litewallet/Extensions/SafariServices+Extension.swift +++ b/litewallet/Extensions/SafariServices+Extension.swift @@ -23,13 +23,10 @@ struct WebView: UIViewRepresentable { } func updateUIView(_ webview: WKWebView, context _: Context) { - print("::: webview \(webview.frame.size)") - webview.endEditing(true) if scrollToSignup { let point = CGPoint(x: 0, y: webview.scrollView.contentSize.height - webview.frame.size.height / 2) - print("::: point \(point)") webview.scrollView.setContentOffset(point, animated: true) DispatchQueue.main.async { diff --git a/litewallet/Platform/BRAPIClient.swift b/litewallet/Platform/BRAPIClient.swift index c7834643a..0e49f977f 100644 --- a/litewallet/Platform/BRAPIClient.swift +++ b/litewallet/Platform/BRAPIClient.swift @@ -15,11 +15,6 @@ let BRAPIClientErrorDomain = "BRApiClientErrorDomain" } } -public enum BRAPIClientError: Error { - case malformedDataError - case unknownError -} - public typealias URLSessionTaskHandler = (Data?, HTTPURLResponse?, NSError?) -> Void public typealias URLSessionChallengeHandler = (URLSession.AuthChallengeDisposition, URLCredential?) -> Void diff --git a/litewallet/Platform/BRWebViewController.swift b/litewallet/Platform/BRWebViewController.swift index 19f419cba..f92dd38ed 100644 --- a/litewallet/Platform/BRWebViewController.swift +++ b/litewallet/Platform/BRWebViewController.swift @@ -8,7 +8,6 @@ import WebKit var wkProcessPool: WKProcessPool var webView: WKWebView? var server = BRHTTPServer() - var debugEndpoint: String? var mountPoint: String var walletManager: WalletManager let store: Store @@ -16,10 +15,7 @@ import WebKit let partner: String? let activityIndicator: UIActivityIndicatorView var didLoad = false - var didAppear = false - var didLoadTimeout = 2500 - var waitTimeout = 90 - // we are also a socket server which sends didview/didload events to the listening client(s) + var didAppear = false // we are also a socket server which sends didview/didload events to the listening client(s) var sockets = [String: BRWebSocket]() // this is the data that occasionally gets sent to the above connected sockets @@ -34,8 +30,6 @@ import WebKit return URL(string: "http://127.0.0.1:\(server.port)\(mountPoint)")! } - private let messageUIPresenter = MessageUIPresenter() - init(partner: String?, mountPoint: String = "/", walletManager: WalletManager, store: Store, noAuthApiClient: BRAPIClient? = nil) { wkProcessPool = WKProcessPool() diff --git a/litewallet/StartView.swift b/litewallet/StartView.swift index 60cacbd9e..3525f5cb8 100644 --- a/litewallet/StartView.swift +++ b/litewallet/StartView.swift @@ -120,14 +120,17 @@ struct StartView: View { .alertMessage[startViewModel.currentLanguage.rawValue], isPresented: $delayedSelect) { HStack { - Button(S.Button.yes.localize(), role: .cancel) { - startViewModel.setLanguage(code: startViewModel.currentLanguage.code) - selectedLang = false - } - Button(S.Button.cancel.localize(), role: .destructive) { - // Dismisses - selectedLang = false - } + Button(startViewModel + .yesLabel[startViewModel.currentLanguage.rawValue], role: .cancel) { + // Changes and Dismisses + startViewModel.setLanguage(code: startViewModel.currentLanguage.code) + selectedLang = false + } + Button(startViewModel + .cancelLabel[startViewModel.currentLanguage.rawValue], role: .destructive) { + // Dismisses + selectedLang = false + } } } Spacer() @@ -137,7 +140,7 @@ struct StartView: View { language: startViewModel.currentLanguage, didTapContinue: $didContinue) .environmentObject(startViewModel) - .navigationBarBackButtonHidden(true) + .navigationBarBackButtonHidden(false) ) { ZStack { RoundedRectangle(cornerRadius: bigButtonCornerRadius) @@ -163,7 +166,7 @@ struct StartView: View { language: startViewModel.currentLanguage, didTapContinue: $didContinue) .environmentObject(startViewModel) - .navigationBarBackButtonHidden(true) + .navigationBarBackButtonHidden(false) ) { ZStack { RoundedRectangle(cornerRadius: bigButtonCornerRadius) diff --git a/litewallet/StartViewModel.swift b/litewallet/StartViewModel.swift index 849ea475d..70a8b7646 100644 --- a/litewallet/StartViewModel.swift +++ b/litewallet/StartViewModel.swift @@ -134,7 +134,7 @@ class StartViewModel: ObservableObject { // MARK: - Lengthy elements /// Set these to the bottom to make the others more readable - + /// These are semi-hardcoded because the state is in flux let taglines: [String] = [ "The most secure and easiest way to use Litecoin.", "使用莱特币最安全、最简单的方式。", @@ -168,6 +168,41 @@ class StartViewModel: ObservableObject { "Dili Türkçe olarak değiştirmek istediğinizden emin misiniz?", "Ви впевнені, що хочете змінити мову на українську?", ] + + let yesLabel: [String] = [ + "Yes", + "是的", + "是的", + "Oui", + "Ja", + "Ya", + "SÌ", + "はい", + "예", + "Sim", + "Да", + "Sí", + "Evet", + "Так", + ] + + let cancelLabel: [String] = [ + "Cancel", + "取消", + "取消", + "Annuler", + "Stornieren", + "Membatalkan", + "Annulla", + "キャンセル", + "취소", + "Cancelar", + "Отмена", + "Cancelar", + "İptal etmek", + "Скасувати", + ] + func stringToCurrentLanguage(languageString: String) -> LanguageSelection { switch languageString { case "English": diff --git a/litewallet/Strings/Base.lproj/Localizable.strings b/litewallet/Strings/Base.lproj/Localizable.strings index 9b2d823e6..d07f2e339 100644 --- a/litewallet/Strings/Base.lproj/Localizable.strings +++ b/litewallet/Strings/Base.lproj/Localizable.strings @@ -460,21 +460,6 @@ /* Upgrade PIN prompt title. */ "Prompts.SetPin.title" = "Set PIN"; -/* Push notifications settings view body */ -"Notifications.body" = "Turn on notifications to receive special messages from Litewallet in the future."; - -/* Push notifications toggle switch label */ -"Notifications.label" = "Push Notifications"; - -/* Push notifications are off label */ -"Notifications.off" = "Off"; - -/* Push notifications are on label */ -"Notifications.on" = "On"; - -/* Push notifications settings view title label */ -"Notifications.title" = "Notifications"; - /* Address copied message. */ "Receive.copied" = "Copied to clipboard."; @@ -1075,9 +1060,6 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%1$d of %2$d"; -/* as of a time or date */ -"Conjunction.asOf" = "as of"; - /* No comment provided by engineer. */ "Copy" = "Copy"; @@ -1470,3 +1452,27 @@ /* Fees Blank: */ "Send.feeBlank" = "Fees:"; + +/* Network */ +"Send.networkFee" = "Network"; + +/* Service */ +"Send.serviceFee" = "Service"; + +/* Signup cancel */ +"Notifications.signupCancel" = "No, thanks"; + +/* "Email title" */ +"Notifications.emailTitle" = "Don't a miss a thing!"; + +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = "Sign up to hear about updates & contests."; + +/* "Email address label" */ +"Notifications.emailLabel" = "Email address"; + +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = "Enter here"; + +/* "Language preference label" */ +"Notifications.languagePreference" = "Preferred language:"; diff --git a/litewallet/Strings/da.lproj/Localizable.strings b/litewallet/Strings/da.lproj/Localizable.strings index 9b7c49efa..2c191a0d9 100755 --- a/litewallet/Strings/da.lproj/Localizable.strings +++ b/litewallet/Strings/da.lproj/Localizable.strings @@ -166,9 +166,6 @@ /* Amount to Donate: ($1.00) */ "Confirmation.donateLabel" = "Beløb til donation:"; -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = "Netværksgebyr:"; - /* eg. Processing with Donation time: This transaction will take 10-30 minutes to process. */ "Confirmation.processingAndDonationTime" = "Behandlingstid: Disse transaktioner tager %1$@ minutter at behandle."; @@ -202,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "Ord #%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "fra"; - /* No comment provided by engineer. */ "Copy" = "Kopi"; @@ -379,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "Ryd"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "KORT"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "Kortbalance"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "Log ud og aktiver 2FA for at tillade overførsler"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "- Registrering og login\n- Tilgængelig kortsaldo\n- Nulstil adgangskode\n- Ingen overførsel til Litewallet\n- Kun USA"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "Litecoin Card har i øjeblikket begrænset funktionalitet i Litewallet."; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "Besøg litecoin.getblockcard.com for fuld adgang"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Litecoin-kort Beta"; - -/* Enter code */ -"LitecoinCard.enterCode" = "Indtast kode"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "Indtast koden, der for nylig blev sendt til din Litecoin Card -kontos e -mail."; - -/* Failed Login */ -"LitecoinCard.failed.login" = "Login mislykkedes"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "Glemt kodeord?"; - -/* Login */ -"LitecoinCard.login" = "Log på"; - -/* Logout */ -"LitecoinCard.logout" = "Log ud"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "Litecoin -kort"; - -/* Register */ -"LitecoinCard.registerCard" = "Tilmeld"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "Tilmeld dig Litecoin-kort"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "Registrerer bruger ..."; - -/* address */ -"LitecoinCard.Registration.address" = "Vejnavn"; - -/* city */ -"LitecoinCard.Registration.city" = "By"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "bekræft kodeord"; - -/* country */ -"LitecoinCard.Registration.country" = "Land"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "fornavn"; - -/* identification */ -"LitecoinCard.Registration.identification" = "Identifikation"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "Kend din kunde ID-type"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "ID-type"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "efternavn"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "Mobilnummer"; - -/* password */ -"LitecoinCard.Registration.password" = "adgangskode"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "Stat / provins"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "Email"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "Må ikke være tom"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "Ugyldig emailadresse"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "Mobilnummeret skal have mindst 10 cifre"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "Mobilnummer er påkrævet"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "Adgangskoden skal indeholde mindst 6 tegn"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "Adgangskoden skal bestå af mere end 6 tegn med mindst et tegn og et numerisk tegn"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "Adgangskode er påkrævet"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "påkrævet område"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "Postnummer"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "Der var et problem med din registrering. Kontroller dine data, og prøv igen."; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "Du har tilmeldt dig! Kontroller og bekræft din e-mail. Så kom tilbage for at logge ind."; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "Nulstille kodeord"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "Indtast den e-mail-adresse, der er knyttet til din Litecoin Card-konto, og se efter en e-mail med nulstillingsinstruktioner."; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "Overfør til kort"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "Overførsel til Litewallet"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "Vælg den overførende tegnebog:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "Adresse"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "Litewallet balance"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "Skub for at indstille overførselsbeløb"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "Start overførsel"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "Overførsel"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "Der opstod en fejl. Skift venligst 2FA til *Aktiveret*, indtast den e -mailede kode, og prøv igen."; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA Ikke aktiveret"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA aktiveret"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "Besøg https://litecoin.dashboard.getblockcard.com/password/forgot for at nulstille din adgangskode."; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -1420,20 +1246,23 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%1$d af %2$d"; -/* Push notifications settings view body */ -"Notifications.body" = ""; +/* "Email address label" */ +"Notifications.emailLabel" = ""; -/* Push notifications toggle switch label */ -"Notifications.label" = ""; +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = ""; -/* Push notifications are off label */ -"Notifications.off" = ""; +/* "Email title" */ +"Notifications.emailTitle" = ""; -/* Push notifications are on label */ -"Notifications.on" = ""; +/* "Language preference label" */ +"Notifications.languagePreference" = ""; -/* Push notifications settings view title label */ -"Notifications.title" = ""; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = ""; + +/* Signup cancel */ +"Notifications.signupCancel" = ""; /* Fee: $0.01 */ "Send.bareFee" = ""; @@ -1441,6 +1270,12 @@ /* Fees Blank: */ "Send.feeBlank" = ""; +/* Network */ +"Send.networkFee" = ""; + +/* Service */ +"Send.serviceFee" = ""; + /* domain */ "Send.UnstoppableDomains.domain" = ""; diff --git a/litewallet/Strings/de.lproj/Localizable.strings b/litewallet/Strings/de.lproj/Localizable.strings index 249341489..ac9b82526 100755 --- a/litewallet/Strings/de.lproj/Localizable.strings +++ b/litewallet/Strings/de.lproj/Localizable.strings @@ -199,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "Wort #%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "ab"; - /* No comment provided by engineer. */ "Copy" = "Kopieren"; @@ -376,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "Löschen"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "Card"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "Kartenguthaben"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "Melden Sie sich ab und aktivieren Sie 2FA, um Übertragungen zuzulassen"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "- Registratie en inlogge\n- Beschikbaar kaartsaldo\n- Wachtwoord opnieuw instelle\n- Geen overdracht naar Litewallet\n- Alleen in de USA."; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "Die Litecoin Card verfügt derzeit über eingeschränkte Funktionen in Litewallet."; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "Besuchen Sie litecoin.getblockcard.com für den vollständigen Zugriff"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Litecoin Card Beta"; - -/* Enter code */ -"LitecoinCard.enterCode" = "Code eingeben"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "Geben Sie den Code ein, der kürzlich an die E-Mail-Adresse Ihres Litecoin Card-Kontos gesendet wurde."; - -/* Failed Login */ -"LitecoinCard.failed.login" = "Anmeldung fehlgeschlagen"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "Passwort vergessen?"; - -/* Login */ -"LitecoinCard.login" = "Anmeldung"; - -/* Logout */ -"LitecoinCard.logout" = "Ausloggen"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "Litecoin-Karte"; - -/* Register */ -"LitecoinCard.registerCard" = "Registrieren"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "Registrieren für Litecoin Card"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "Benutzer registrieren ..."; - -/* address */ -"LitecoinCard.Registration.address" = "Adresse"; - -/* city */ -"LitecoinCard.Registration.city" = "Stadt"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "Kennwort bestätigen"; - -/* country */ -"LitecoinCard.Registration.country" = "Land"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "Vorname"; - -/* identification */ -"LitecoinCard.Registration.identification" = "Identifizierung"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "Kenne deinen Kunden ID-Typ"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "ID Typ"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "Familienname"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "Handynummer"; - -/* password */ -"LitecoinCard.Registration.password" = "Passwort"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "Staat / Provinz"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "E-Mail (Benutzername)"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "Darf nicht leer sein"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "Ungültige E-Mail-Adresse"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "Die Handynummer muss mindestens 10 Ziffern haben"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "Handynummer ist erforderlich"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "Das Passwort muss mindestens 6 Zeichen lang sein"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "Das Passwort muss aus mehr als 6 Zeichen bestehen, mit mindestens einem Zeichen und einem numerischen Zeichen"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "Passwort wird benötigt"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "Pflichtfeld"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "Postleitzahl"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "Bei Ihrer Registrierung ist ein Problem aufgetreten. Bitte überprüfen Sie Ihre Daten und versuchen Sie es erneut."; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "Sie haben sich registriert! Bitte überprüfen und bestätigen Sie Ihre E-Mail. Dann komm zurück zum Login."; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "Passwort zurücksetzen"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "Geben Sie die E-Mail-Adresse ein, die Ihrem Litecoin Card-Konto zugeordnet ist, und suchen Sie nach einer E-Mail mit Anweisungen zum Zurücksetzen."; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "Auf Karte übertragen"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "Auf Litewallet übertragen"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "Wählen Sie die übertragende Brieftasche:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "Adresse"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "Litewallet-Guthaben"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "Schieben, um den Überweisungsbetrag festzulegen"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "Übertragung starten"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "Überweisen"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "Es gab einen Fehler. Bitte schalten Sie 2FA auf *Aktiviert* um, geben Sie den per E-Mail gesendeten Code ein und versuchen Sie es erneut."; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA nicht aktiviert"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA aktiviert"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "Besuchen Sie https://litecoin.dashboard.getblockcard.com/password/forgot, um Ihr Passwort zurückzusetzen."; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -628,20 +457,23 @@ /* Node Selector view title */ "NodeSelector.title" = "Litecoin-Nodes"; -/* Notifications will be added later but users can set their preferences now, which is why we specify "in the future". */ -"Notifications.body" = "Schalten Sie Benachrichtigungen ein, um in Zukunft besondere Meldungen von Litewallet zu erhalten."; +/* "Email address label" */ +"Notifications.emailLabel" = "E-Mail-Adresse"; -/* Push notifications toggle switch label */ -"Notifications.label" = "Push-Benachrichtigungen"; +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = "Hier eintreten"; -/* Push notifications are off label */ -"Notifications.off" = "Deaktivieren"; +/* "Email title" */ +"Notifications.emailTitle" = "Miss nichts verpassen!"; -/* Push notifications are on label */ -"Notifications.on" = "Aktivieren"; +/* "Language preference label" */ +"Notifications.languagePreference" = "Bevorzugte Sprache:"; -/* Push notifications settings view title label */ -"Notifications.title" = "Benachrichtigungen"; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = "Melden Sie sich an, um über Updates und Wettbewerbe zu erfahren."; + +/* Signup cancel */ +"Notifications.signupCancel" = "Nein danke."; /* Bad Payment request alert title */ "PaymentProtocol.Errors.badPaymentRequest" = "Ungültige Zahlungsanforderung"; @@ -901,9 +733,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Geben Sie eine Litecoin-Adresse ein"; -/* Network Fee: $0.01 */ -"Send.fee" = "Netzwerkgebühr: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "Gebühren: %1$@"; @@ -931,6 +760,9 @@ /* Loading request activity view message */ "Send.loadingRequest" = "Anfrage wird geladen"; +/* Network */ +"Send.networkFee" = "Netzwerk"; + /* Empty address alert message */ "Send.noAddress" = "Bitte gib die Adresse des Empfängers ein."; @@ -952,6 +784,9 @@ /* Send button label (the action, "press here to send") */ "Send.sendLabel" = "Senden"; +/* Service */ +"Send.serviceFee" = "Service"; + /* Send screen title (as in "this is the screen for sending money") */ "Send.title" = "Senden"; @@ -1455,6 +1290,3 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%1$d von %2$d"; - -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = ""; diff --git a/litewallet/Strings/en.lproj/Localizable.strings b/litewallet/Strings/en.lproj/Localizable.strings index 2fcb1f31c..a16a32788 100644 --- a/litewallet/Strings/en.lproj/Localizable.strings +++ b/litewallet/Strings/en.lproj/Localizable.strings @@ -166,9 +166,6 @@ /* Amount to Donate: ($1.00) */ "Confirmation.donateLabel" = "Amount to Donate:"; -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = "Network Fee:"; - /* eg. Processing with Donation time: This transaction will take 10-30 minutes to process. */ "Confirmation.processingAndDonationTime" = "Processing time: These transactions will take %1$@ minutes to process."; @@ -202,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "Word #%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "as of"; - /* No comment provided by engineer. */ "Copy" = "Copy"; @@ -379,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "Wipe"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "CARD"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "Card balance"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "Logout & Enable 2FA to allow Transfers"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "- Registration & Login\n- Available card balance\n- Reset password\n- No transfer to Litewallet\n- US Only"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "Litecoin Card currently has limited functionality in Litewallet."; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "Visit litecoin.getblockcard.com for full access"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Litecoin Card Beta"; - -/* Enter code */ -"LitecoinCard.enterCode" = "Enter Code"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "Enter the code that was recently sent to your Litecoin Card account email."; - -/* Failed Login */ -"LitecoinCard.failed.login" = "Login failed"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "Forgot password?"; - -/* Login */ -"LitecoinCard.login" = "Login"; - -/* Logout */ -"LitecoinCard.logout" = "Logout"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "Litecoin Card"; - -/* Register */ -"LitecoinCard.registerCard" = "Register"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "Register for Litecoin Card"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "Registering user..."; - -/* address */ -"LitecoinCard.Registration.address" = "Address"; - -/* city */ -"LitecoinCard.Registration.city" = "City"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "Confirm password"; - -/* country */ -"LitecoinCard.Registration.country" = "Country"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "First name"; - -/* identification */ -"LitecoinCard.Registration.identification" = "Identification"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "ID Number"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "ID Type"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "Last name"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "Mobile number"; - -/* password */ -"LitecoinCard.Registration.password" = "Password"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "State / Province"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "Email (Username)"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "Must not be empty"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "Invalid e-mail Address"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "Mobile number must have at least 10 digits"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "Mobile number is required"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "Password must have at least 6 characters"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "Password must be more than 6 characters, with at least one character and one numeric character"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "Password is Required"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "Required field"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "Zip / Postcode"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "There was a problem with your registration. \nPlease check your data and try again."; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "You have registered!\nPlease check and confirm your email. Then come back to login."; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "Reset password"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "Enter the email address associated with your Litecoin Card account & look for an email with reset instructions."; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "Transfer to Card"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "Transfer to Litewallet"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "Choose the transferring wallet:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "Address"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "Litewallet balance"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "Slide to set transfer amount"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "Start transfer"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "Transfer"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "There was an error. Please toggle 2FA to *Enabled* , enter the emailed code, and try again."; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA Not Enabled"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA Enabled"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "Visit https://litecoin.dashboard.getblockcard.com/password/forgot\nto reset your password."; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -631,20 +457,23 @@ /* Node Selector view title */ "NodeSelector.title" = "Litecoin Nodes"; -/* Push notifications settings view body */ -"Notifications.body" = "Turn on notifications to receive special messages from Litewallet in the future."; +/* "Email address label" */ +"Notifications.emailLabel" = "Email address"; + +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = "Enter here"; -/* Push notifications toggle switch label */ -"Notifications.label" = "Push Notifications"; +/* "Email title" */ +"Notifications.emailTitle" = "Don't a miss a thing!"; -/* Push notifications are off label */ -"Notifications.off" = "Off"; +/* "Language preference label" */ +"Notifications.languagePreference" = "Preferred language:"; -/* Push notifications are on label */ -"Notifications.on" = "On"; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = "Sign up to hear about updates & contests."; -/* Push notifications settings view title label */ -"Notifications.title" = "Notifications"; +/* Signup cancel */ +"Notifications.signupCancel" = "No, thanks"; /* Bad Payment request alert title */ "PaymentProtocol.Errors.badPaymentRequest" = "Bad Payment Request"; @@ -904,9 +733,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Enter a Litecoin address"; -/* Network Fee: $0.01 */ -"Send.fee" = "Network Fee: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "Fees: %1$@"; @@ -934,6 +760,9 @@ /* Loading request activity view message */ "Send.loadingRequest" = "Loading Request"; +/* Network */ +"Send.networkFee" = "Network"; + /* Empty address alert message */ "Send.noAddress" = "Please enter the recipient's address."; @@ -955,6 +784,9 @@ /* Send button label */ "Send.sendLabel" = "Send"; +/* Service */ +"Send.serviceFee" = "Service"; + /* Send modal title */ "Send.title" = "Send"; diff --git a/litewallet/Strings/es.lproj/Localizable.strings b/litewallet/Strings/es.lproj/Localizable.strings index 8bc4ebc6f..09210630b 100755 --- a/litewallet/Strings/es.lproj/Localizable.strings +++ b/litewallet/Strings/es.lproj/Localizable.strings @@ -200,9 +200,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "Palabra #%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "a partir de"; - /* No comment provided by engineer. */ "Copy" = "Copiar"; @@ -377,175 +374,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "Borrar"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "TARJETA"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "Balance de tarjeta"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "Cerrar sesión y habilitar 2FA para permitir transferencias"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "- Registro e inicio de sesión\n- Saldo de tarjeta disponible\n- Restablecer contraseña\n- Sin transferencia a Litewallet\n- Estados Unidos solamente"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "La tarjeta Litecoin actualmente tiene una funcionalidad limitada en Litewallet."; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "Visite litecoin.getblockcard.com para obtener acceso completo"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Tarjeta Litecoin Beta"; - -/* Enter code */ -"LitecoinCard.enterCode" = "Introduzca el código"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "Ingrese el código que se envió recientemente al correo electrónico de su cuenta de tarjeta Litecoin."; - -/* Failed Login */ -"LitecoinCard.failed.login" = "error de inicio de sesion"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "¿Se te olvidó tu contraseña?"; - -/* Login */ -"LitecoinCard.login" = "Iniciar sesión"; - -/* Logout */ -"LitecoinCard.logout" = "Cerrar sesión"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "Tarjeta Litecoin"; - -/* Register */ -"LitecoinCard.registerCard" = "Registrarse"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "Registrarse para la tarjeta Litecoin"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "Registrando usuario ..."; - -/* address */ -"LitecoinCard.Registration.address" = "Dirección"; - -/* city */ -"LitecoinCard.Registration.city" = "Ciudad"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "Confirmar contraseña"; - -/* country */ -"LitecoinCard.Registration.country" = "País"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "primer nombre"; - -/* identification */ -"LitecoinCard.Registration.identification" = "Identificación"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "Conozca a su cliente Tipo de identificación"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "tipo de identificación"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "apellido"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "Número de teléfono móvil"; - -/* password */ -"LitecoinCard.Registration.password" = "contraseña"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "Provincia del estado"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "Email"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "No debe estar vacío"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "Dirección de correo electrónico inválida"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "El número de móvil debe tener al menos 10 dígitos"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "Se requiere un número de celular"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "La contraseña debe tener al menos 6 caracteres"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "La contraseña debe tener más de 6 caracteres, con al menos un carácter y un carácter numérico"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "Se requiere contraseña"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "Campo requerido"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "Código postal"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "Hubo un problema con tu registro. Verifique sus datos y vuelva a intentarlo."; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "¡Te has registrado! Por favor revise y confirme su correo electrónico. Luego vuelve a iniciar sesión."; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "Restablecer la contraseña"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "Ingrese la dirección de correo electrónico asociada con su cuenta de tarjeta Litecoin y busque un correo electrónico con instrucciones para restablecer."; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "Transferir a tarjeta"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "Transferencia a Litewallet"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "Elija la billetera de transferencia:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "Dirección"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "Saldo de Litewallet -"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "Desliza para establecer el monto de la transferencia"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "Iniciar transferencia"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "Transferir"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "Hubo un error. Cambie 2FA a * Habilitado *, ingrese el código enviado por correo electrónico y vuelva a intentarlo."; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA no habilitado"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA habilitado"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "Visita https://litecoin.dashboard.getblockcard.com/password/forgot para restablecer tu contraseña."; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -630,20 +458,23 @@ /* Node Selector view title */ "NodeSelector.title" = "Nodos Litecoin"; -/* Notifications will be added later but users can set their preferences now, which is why we specify "in the future". */ -"Notifications.body" = "Activa las notificaciones para recibir mensajes especiales de Litewallet en el futuro."; +/* "Email address label" */ +"Notifications.emailLabel" = "Dirección de correo electrónico"; -/* Push notifications toggle switch label */ -"Notifications.label" = "Notificaciones push"; +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = "Entre aquí"; -/* Push notifications are off label */ -"Notifications.off" = "Desactivado"; +/* "Email title" */ +"Notifications.emailTitle" = "¡No te pierdas nada!"; -/* Push notifications are on label */ -"Notifications.on" = "Activado"; +/* "Language preference label" */ +"Notifications.languagePreference" = "Idioma preferido:"; -/* Push notifications settings view title label */ -"Notifications.title" = "Notificaciones"; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = "Regístrese para escuchar sobre actualizaciones y concursos."; + +/* Signup cancel */ +"Notifications.signupCancel" = "No, gracias."; /* Bad Payment request alert title */ "PaymentProtocol.Errors.badPaymentRequest" = "Solicitud de pago incorrecta"; @@ -903,9 +734,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Introduzca una dirección de Litecoin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Tarifa de red: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "Honorarios: %1$@"; @@ -933,6 +761,9 @@ /* Loading request activity view message */ "Send.loadingRequest" = "Cargando solicitud"; +/* Network */ +"Send.networkFee" = "Red"; + /* Empty address alert message */ "Send.noAddress" = "Introduce la dirección del destinatario."; @@ -954,6 +785,9 @@ /* Send button label (the action, "press here to send") */ "Send.sendLabel" = "Enviar"; +/* Service */ +"Send.serviceFee" = "Servicio"; + /* Send screen title (as in "this is the screen for sending money") */ "Send.title" = "Enviar"; @@ -1457,6 +1291,3 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%1$d de %2$d"; - -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = ""; diff --git a/litewallet/Strings/fr.lproj/Localizable.strings b/litewallet/Strings/fr.lproj/Localizable.strings index 55ee63147..566c3e0f1 100755 --- a/litewallet/Strings/fr.lproj/Localizable.strings +++ b/litewallet/Strings/fr.lproj/Localizable.strings @@ -199,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "Mot no.%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "au"; - /* No comment provided by engineer. */ "Copy" = "Copie"; @@ -376,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "Supprimer"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "CARTE"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "Solde de la carte"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "Déconnectez-vous et activez 2FA pour autoriser les transferts"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "- Inscription et connexion\n- Solde de la carte disponible\n- Réinitialiser le mot de passe\n- Aucun transfert vers Litewallet\n- États-Unis uniquement"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "La carte Litecoin a actuellement des fonctionnalités limitées dans Litewallet."; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "Visitez litecoin.getblockcard.com pour un accès complet"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Bêta de la carte Litecoin"; - -/* Enter code */ -"LitecoinCard.enterCode" = "Entrez le code"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "Entrez le code qui a été récemment envoyé à l'e-mail de votre compte Litecoin Card."; - -/* Failed Login */ -"LitecoinCard.failed.login" = "Échec de la connexion"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "Mot de passe oublié?"; - -/* Login */ -"LitecoinCard.login" = "S'identifier"; - -/* Logout */ -"LitecoinCard.logout" = "Se déconnecter"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "Carte Litecoin"; - -/* Register */ -"LitecoinCard.registerCard" = "S'inscrire"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "Inscrivez-vous à la carte Litecoin"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "Enregistrement de l'utilisateur ..."; - -/* address */ -"LitecoinCard.Registration.address" = "Adresse de rue"; - -/* city */ -"LitecoinCard.Registration.city" = "Ville"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "Confirmez le mot de passe"; - -/* country */ -"LitecoinCard.Registration.country" = "Pays"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "Prénom"; - -/* identification */ -"LitecoinCard.Registration.identification" = "Identification"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "Connaissez votre client Type d'identification"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "Type d'identification"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "nom de famille"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "Numéro de portable"; - -/* password */ -"LitecoinCard.Registration.password" = "mot de passe"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "État / Province"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "Email"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "Ne doit pas être vide"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "Adresse e-mail invalide"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "Le numéro de mobile doit comporter au moins 10 chiffres"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "Le numéro de portable est requis"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "Le mot de passe doit comporter au moins 6 caractères"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "Le mot de passe doit contenir plus de 6 caractères, avec au moins un caractère et un caractère numérique"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "Mot de passe requis"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "champs requis"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "Code postal"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "Un problème est survenu lors de votre inscription. Veuillez vérifier vos données et réessayer."; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "Vous vous êtes inscrit! Veuillez vérifier et confirmer votre email. Puis revenez vous connecter."; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "Réinitialiser le mot de passe"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "Entrez l'adresse e-mail associée à votre compte Litecoin Card et recherchez un e-mail avec des instructions de réinitialisation."; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "Transfert vers la carte"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "Transfert vers Litewallet"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "Choisissez le portefeuille de transfert :"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "Adresse"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "Solde du Litewallet"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "Faites glisser pour définir le montant du transfert"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "Démarrer le transfert"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "Transférer"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "Il y avait une erreur. Veuillez basculer 2FA sur *Activé*, entrez le code envoyé par e-mail et réessayez."; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA non activé"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA activé"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "Visitez le site https://litecoin.dashboard.getblockcard.com/password/forgot pour réinitialiser votre mot de passe."; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -626,22 +455,25 @@ "NodeSelector.statusLabel" = "Statut de connexion du nœud"; /* Node Selector view title */ -"NodeSelector.title" = "Nœuds Litecoin"; +"NodeSelector.title" = "Nœud litecoin"; -/* Notifications will be added later but users can set their preferences now, which is why we specify "in the future". */ -"Notifications.body" = "Activez les notifications pour recevoir des messages spéciaux de Litewallet à l'avenir."; +/* "Email address label" */ +"Notifications.emailLabel" = "Adresse e-mail"; -/* Push notifications toggle switch label */ -"Notifications.label" = "Notifications push"; +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = "Entrer ici"; -/* Push notifications are off label */ -"Notifications.off" = "Off"; +/* "Email title" */ +"Notifications.emailTitle" = "Ne manquez rien!"; -/* Push notifications are on label */ -"Notifications.on" = "On"; +/* "Language preference label" */ +"Notifications.languagePreference" = "Langue préférée:"; -/* Push notifications settings view title label */ -"Notifications.title" = "Notifications"; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = "Inscrivez-vous pour entendre parler des mises à jour et des concours."; + +/* Signup cancel */ +"Notifications.signupCancel" = "Non merci."; /* Bad Payment request alert title */ "PaymentProtocol.Errors.badPaymentRequest" = "Mauvaise demande de paiement"; @@ -901,9 +733,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Entrez une adresse Litecoin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Frais de réseau : %1$@"; - /* Fees: $0.01*/ "Send.fee" = "Frais: %1$@"; @@ -931,6 +760,9 @@ /* Loading request activity view message */ "Send.loadingRequest" = "Chargement de la demande"; +/* Network */ +"Send.networkFee" = "Réseau"; + /* Empty address alert message */ "Send.noAddress" = "Entrez l'adresse du destinataire."; @@ -952,6 +784,9 @@ /* Send button label (the action, "press here to send") */ "Send.sendLabel" = "Envoyer"; +/* Service */ +"Send.serviceFee" = "Service"; + /* Send screen title (as in "this is the screen for sending money") */ "Send.title" = "Envoyer"; @@ -1455,6 +1290,3 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%1$d de %2$d"; - -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = ""; diff --git a/litewallet/Strings/id.lproj/Localizable.strings b/litewallet/Strings/id.lproj/Localizable.strings index 76e78f71a..44088091d 100644 --- a/litewallet/Strings/id.lproj/Localizable.strings +++ b/litewallet/Strings/id.lproj/Localizable.strings @@ -199,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "Kata #%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "pada"; - /* No comment provided by engineer. */ "Copy" = "Salinan"; @@ -376,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "Hapus"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "KARTU"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "Saldo kartu"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "Logout & Aktifkan 2FA untuk mengizinkan Transfer"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "- Pendaftaran & Login\n- Saldo kartu yang tersedia\n- Reset kata sandi\n- Tidak ada transfer ke Litewallet\n- AS Sudah"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "Kartu Litecoin saat ini memiliki fungsi terbatas di Litewallet."; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "Kunjungi litecoin.getblockcard.com untuk akses penuh"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Kartu Litecoin Beta"; - -/* Enter code */ -"LitecoinCard.enterCode" = "Memasukkan kode"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "Masukkan kode yang baru saja dikirim ke email akun Kartu Litecoin Anda."; - -/* Failed Login */ -"LitecoinCard.failed.login" = "Gagal masuk"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "Tidak ingat kata sandi?"; - -/* Login */ -"LitecoinCard.login" = "Gabung"; - -/* Logout */ -"LitecoinCard.logout" = "Keluar"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "Kartu Litecoin"; - -/* Register */ -"LitecoinCard.registerCard" = "Daftar"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "Daftar untuk Kartu Litecoin"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "Mendaftarkan pengguna ..."; - -/* address */ -"LitecoinCard.Registration.address" = "Alamat jalan"; - -/* city */ -"LitecoinCard.Registration.city" = "Kota"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "konfirmasi sandi"; - -/* country */ -"LitecoinCard.Registration.country" = "Negara"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "nama depan"; - -/* identification */ -"LitecoinCard.Registration.identification" = "Identifikasi"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "Kenali pelanggan Anda Jenis ID"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "Jenis ID"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "nama keluarga"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "Nomor handphone"; - -/* password */ -"LitecoinCard.Registration.password" = "kata sandi"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "Negara Bagian / Provinsi"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "Surel"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "Wajib diisi"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "Alamat email salah"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "Nomor ponsel setidaknya harus terdiri dari 10 digit"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "Nomor ponsel diperlukan"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "Kata sandi harus memiliki setidaknya 6 karakter"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "Kata sandi harus lebih dari 6 karakter, dengan setidaknya satu karakter dan satu karakter numerik"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "Kata sandi diperlukan"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "kolom yang harus diisi"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "Kode Pos"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "Ada masalah dengan pendaftaran Anda. Silakan periksa data Anda dan coba lagi."; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "Anda telah mendaftar! Silakan periksa dan konfirmasi email Anda. Kemudian kembali untuk masuk."; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "Setel ulang kata sandi"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "Masukkan alamat email yang terkait dengan akun Kartu Litecoin Anda & cari email dengan instruksi reset."; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "Transfer ke Kartu"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "Transfer ke Litewallet"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "Pilih dompet transfer:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "Alamat"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "Litewallet saldo"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "Geser untuk mengatur jumlah transfer"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "Mulai transfer"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "Transfer"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "Ada kesalahan. Harap alihkan 2FA ke *Diaktifkan*, masukkan kode yang dikirim melalui email, dan coba lagi."; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA Tidak Diaktifkan"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA Diaktifkan"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "Kunjungi https://litecoin.dashboard.getblockcard.com/password/forgot untuk mengatur ulang kata sandi Anda."; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -626,22 +455,25 @@ "NodeSelector.statusLabel" = "Status Koneksi Node"; /* Node Selector view title */ -"NodeSelector.title" = "Litecoin Nodes"; +"NodeSelector.title" = "Node Litecoin"; -/* Push notifications settings view body */ -"Notifications.body" = "Aktifkan pemberitahuan untuk menerima pesan khusus dari Litewallet di masa mendatang."; +/* "Email address label" */ +"Notifications.emailLabel" = "Alamat email"; -/* Push notifications toggle switch label */ -"Notifications.label" = "Notifikasi dorongan"; +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = "Masukkan di sini"; -/* Push notifications are off label */ -"Notifications.off" = "Mati"; +/* "Email title" */ +"Notifications.emailTitle" = "Jangan lewatkan apa -apa!"; -/* Push notifications are on label */ -"Notifications.on" = "Hidup"; +/* "Language preference label" */ +"Notifications.languagePreference" = "Bahasa yang disukai:"; -/* Push notifications settings view title label */ -"Notifications.title" = "Notifikasi"; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = "Daftar untuk mendengar tentang pembaruan & kontes."; + +/* Signup cancel */ +"Notifications.signupCancel" = "Tidak, terima kasih."; /* Bad Payment request alert title */ "PaymentProtocol.Errors.badPaymentRequest" = "Permintaan Pembayaran salah"; @@ -901,9 +733,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Masukkan alamat Litecoin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Biaya Jaringan: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "Biaya: %1$@"; @@ -931,6 +760,9 @@ /* Loading request activity view message */ "Send.loadingRequest" = "Memuat Permintaan"; +/* Network */ +"Send.networkFee" = "Jaringan"; + /* Empty address alert message */ "Send.noAddress" = "Silakan masukkan alamat penerima."; @@ -952,6 +784,9 @@ /* Send button label */ "Send.sendLabel" = "Kirim"; +/* Service */ +"Send.serviceFee" = "Melayani"; + /* Send modal title */ "Send.title" = "Kirim"; @@ -1455,6 +1290,3 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%1$d dari %2$d"; - -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = ""; diff --git a/litewallet/Strings/it.lproj/Localizable.strings b/litewallet/Strings/it.lproj/Localizable.strings index b66fd1f8d..699d1997a 100755 --- a/litewallet/Strings/it.lproj/Localizable.strings +++ b/litewallet/Strings/it.lproj/Localizable.strings @@ -199,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "Parola n°%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "come di"; - /* No comment provided by engineer. */ "Copy" = "copia"; @@ -376,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "Cancella"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "CARTA"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "Saldo della carta"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "Esci e abilita 2FA per consentire i trasferimenti"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "- Registrazione e accesso\n- Saldo della carta disponibile\n- Reimposta password\n- Nessun trasferimento a Litewallet\n- Solo negli Stati Uniti"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "Litecoin Card ha attualmente funzionalità limitate in Litewallet."; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "Visita litecoin.getblockcard.com per l'accesso completo"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Litecoin Card Beta"; - -/* Enter code */ -"LitecoinCard.enterCode" = "Inserisci il codice"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "Inserisci il codice che è stato recentemente inviato all'e-mail del tuo account Litecoin Card."; - -/* Failed Login */ -"LitecoinCard.failed.login" = "Accesso fallito"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "Ha dimenticato la password?"; - -/* Login */ -"LitecoinCard.login" = "Accesso"; - -/* Logout */ -"LitecoinCard.logout" = "Disconnettersi"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "Carta Litecoin"; - -/* Register */ -"LitecoinCard.registerCard" = "Registrati"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "Registrati per Litecoin Card"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "Registrazione utente ..."; - -/* address */ -"LitecoinCard.Registration.address" = "Indirizzo"; - -/* city */ -"LitecoinCard.Registration.city" = "Città"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "conferma password"; - -/* country */ -"LitecoinCard.Registration.country" = "Nazione"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "nome di battesimo"; - -/* identification */ -"LitecoinCard.Registration.identification" = "Identificazione"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "Conosci il tuo cliente Tipo di ID"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "Tipo di ID"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "cognome"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "Numero di cellulare"; - -/* password */ -"LitecoinCard.Registration.password" = "parola d'ordine"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "Stato / provincia"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "E-mail"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "Non deve essere vuoto"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "Indirizzo email non valido"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "Il numero di cellulare deve contenere almeno 10 cifre"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "È richiesto il numero di cellulare"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "La password deve contenere almeno 6 caratteri"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "La password deve contenere più di 6 caratteri, con almeno un carattere e un carattere numerico"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "E 'richiesta la password"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "campo obbligatorio"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "Codice postale"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "Si è verificato un problema con la tua registrazione. Controlla i tuoi dati e riprova."; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "Ti sei registrato! Controlla e conferma la tua email. Quindi torna indietro per accedere."; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "Resetta la password"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "Inserisci l'indirizzo e-mail associato al tuo account Litecoin Card e cerca un'e-mail con le istruzioni per il ripristino."; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "Trasferimento su carta"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "Trasferimento a Litewallet"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "Scegli il portafoglio di trasferimento:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "Indirizzo"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "Saldo di Litewallet"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "Scorri per impostare l'importo del trasferimento"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "Inizia il trasferimento"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "Trasferimento"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "C'era un errore. Per favore imposta 2FA su *Abilitato*, inserisci il codice inviato via email e riprova."; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA non abilitato"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA abilitato"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "Visita https://litecoin.dashboard.getblockcard.com/password/forgot per reimpostare la tua password."; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -886,8 +715,8 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Inserire un indirizzo Litecoin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Spese network: %1$@"; +/* Fees: $0.01*/ +"Send.fee" = "Commissioni: %1$@"; /* Fees: $0.01*/ "Send.fee" = "Commissioni: %1$@"; @@ -895,6 +724,9 @@ /* Fees Blank: */ "Send.feeBlank" = "Commissioni:"; +/* Fees Blank: */ +"Send.feeBlank" = "Commissioni:"; + /* Payee identity not certified alert title. */ "Send.identityNotCertified" = "Identità del beneficiario non certificata."; @@ -1441,20 +1273,26 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%1$d di %2$d"; -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = ""; +/* "Email address label" */ +"Notifications.emailLabel" = ""; + +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = ""; + +/* "Email title" */ +"Notifications.emailTitle" = ""; -/* Push notifications settings view body */ -"Notifications.body" = ""; +/* "Language preference label" */ +"Notifications.languagePreference" = ""; -/* Push notifications toggle switch label */ -"Notifications.label" = ""; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = ""; -/* Push notifications are off label */ -"Notifications.off" = ""; +/* Signup cancel */ +"Notifications.signupCancel" = ""; -/* Push notifications are on label */ -"Notifications.on" = ""; +/* Network */ +"Send.networkFee" = ""; -/* Push notifications settings view title label */ -"Notifications.title" = ""; +/* Service */ +"Send.serviceFee" = ""; diff --git a/litewallet/Strings/ja.lproj/Localizable.strings b/litewallet/Strings/ja.lproj/Localizable.strings index ee02fa4e3..cbc3c0363 100755 --- a/litewallet/Strings/ja.lproj/Localizable.strings +++ b/litewallet/Strings/ja.lproj/Localizable.strings @@ -199,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "フレーズ #%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "の時点で"; - /* No comment provided by engineer. */ "Copy" = "コピー"; @@ -376,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "完全削除"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "カード"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "カード残高"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "ログアウトして2FAを有効にし、転送を許可します"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "-登録とログイン\n-利用可能なカード残高\n- パスワードを再設定する\n-Litewalletへの転送なし\n-米国のみ"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "ライトコインカードは現在、ライトウォレットの機能が制限されています。"; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "フルアクセスについては、litecoin.getblockcard.comにアクセスしてください"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "ライトコインカードベータ"; - -/* Enter code */ -"LitecoinCard.enterCode" = "コードを入力する"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "ライトコインカードアカウントのメールに最近送信されたコードを入力します。"; - -/* Failed Login */ -"LitecoinCard.failed.login" = "ログインに失敗しました"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "パスワードをお忘れですか?"; - -/* Login */ -"LitecoinCard.login" = "ログインする"; - -/* Logout */ -"LitecoinCard.logout" = "ログアウト"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "ライトコインカード"; - -/* Register */ -"LitecoinCard.registerCard" = "登録"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "ライトコインカードに登録"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "ユーザーを登録しています..."; - -/* address */ -"LitecoinCard.Registration.address" = "住所"; - -/* city */ -"LitecoinCard.Registration.city" = "市"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "パスワードを認証する"; - -/* country */ -"LitecoinCard.Registration.country" = "国"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "ファーストネーム"; - -/* identification */ -"LitecoinCard.Registration.identification" = "識別"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "顧客を知る IDタイプ"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "IDタイプ"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "苗字"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "携帯電話番号"; - -/* password */ -"LitecoinCard.Registration.password" = "パスワード"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "州/県"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "Eメール"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "空であってはなりません"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "無効なメールアドレス"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "携帯電話番号は10桁以上である必要があります"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "携帯電話番号が必要です"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "パスワードは6文字以上である必要があります"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "パスワードは6文字を超え、少なくとも1文字と1つの数字を使用する必要があります"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "パスワードが必要"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "必須フィールド"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "郵便番号"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "登録に問題がありました。 データを確認して、もう一度お試しください。"; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "登録しました! メールを確認してください。 その後、ログインに戻ります。"; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "パスワードを再設定する"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "ライトコインカードアカウントに関連付けられているメールアドレスを入力し、リセット手順が記載されたメールを探します。"; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "カードに転送"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "Litewalletに転送"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "転送ウォレットを選択します。"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "住所"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "Litewalletバランス"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "スライドして転送量を設定"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "転送を開始します"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "移行"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "エラーが発生しました。 2FAを「有効」に切り替え、電子メールで送信されたコードを入力して、再試行してください。"; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FAが有効になっていません"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA対応"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "https://litecoin.dashboard.getblockcard.com/password/forgot にアクセスしてパスワードをリセットしてください。"; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -628,20 +457,23 @@ /* Node Selector view title */ "NodeSelector.title" = "リテコインノード"; -/* Notifications will be added later but users can set their preferences now, which is why we specify "in the future". */ -"Notifications.body" = "今後、Litewalletからの特別なメッセージを受け取るために通知をオンにしてください。"; +/* "Email address label" */ +"Notifications.emailLabel" = "電子メールアドレス"; -/* Push notifications toggle switch label */ -"Notifications.label" = "プッシュ通知"; +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = "ここに入力"; -/* Push notifications are off label */ -"Notifications.off" = "オフ"; +/* "Email title" */ +"Notifications.emailTitle" = "見逃さないでください!"; -/* Push notifications are on label */ -"Notifications.on" = "オン"; +/* "Language preference label" */ +"Notifications.languagePreference" = "優先言語:"; -/* Push notifications settings view title label */ -"Notifications.title" = "通知"; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = "更新とコンテストについて聞いてサインアップしてください。"; + +/* Signup cancel */ +"Notifications.signupCancel" = "結構です。"; /* Bad Payment request alert title */ "PaymentProtocol.Errors.badPaymentRequest" = "不正な支払い要求"; @@ -901,9 +733,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Litecoinのアドレスを入力してください。"; -/* Network Fee: $0.01 */ -"Send.fee" = "ネットワーク手数料: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "料金: %1$@"; @@ -931,6 +760,9 @@ /* Loading request activity view message */ "Send.loadingRequest" = "ロード要求"; +/* Network */ +"Send.networkFee" = "通信網"; + /* Empty address alert message */ "Send.noAddress" = "受取人のアドレスを入力してください。"; @@ -952,6 +784,9 @@ /* Send button label (the action, "press here to send") */ "Send.sendLabel" = "送金する"; +/* Service */ +"Send.serviceFee" = "通信網"; + /* Send screen title (as in "this is the screen for sending money") */ "Send.title" = "送金"; @@ -1455,6 +1290,3 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%2$dの%1$d"; - -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = ""; diff --git a/litewallet/Strings/ko.lproj/Localizable.strings b/litewallet/Strings/ko.lproj/Localizable.strings index e61b3b6ec..26a7b5830 100755 --- a/litewallet/Strings/ko.lproj/Localizable.strings +++ b/litewallet/Strings/ko.lproj/Localizable.strings @@ -199,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "단어 #%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "현재"; - /* No comment provided by engineer. */ "Copy" = "부"; @@ -376,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "초기화"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "카드"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "카드 잔액"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "전송을 허용하려면 로그아웃 및 2FA 활성화"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "-등록 및 로그인\n-사용 가능한 카드 잔액\n- 암호를 재설정\n-Litewallet으로 양도 불가\n-미국 만 해당"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "라이트 코인 카드는 현재 라이트 월렛에서 제한된 기능을 가지고 있습니다."; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "전체 액세스를 위해 litecoin.getblockcard.com을 방문하십시오."; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "라이트 코인 카드 베타"; - -/* Enter code */ -"LitecoinCard.enterCode" = "코드를 입력"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "최근에 Litecoin 카드 계정 이메일로 전송된 코드를 입력하십시오."; - -/* Failed Login */ -"LitecoinCard.failed.login" = "로그인 실패"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "비밀번호를 잊으 셨나요?"; - -/* Login */ -"LitecoinCard.login" = "로그인"; - -/* Logout */ -"LitecoinCard.logout" = "로그 아웃"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "라이트코인 카드"; - -/* Register */ -"LitecoinCard.registerCard" = "레지스터"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "라이트 코인 카드 등록"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "사용자 등록 중 ..."; - -/* address */ -"LitecoinCard.Registration.address" = "주소"; - -/* city */ -"LitecoinCard.Registration.city" = "시티"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "비밀번호 확인"; - -/* country */ -"LitecoinCard.Registration.country" = "국가"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "이름"; - -/* identification */ -"LitecoinCard.Registration.identification" = "신분증"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "고객 파악 ID 유형"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "ID 유형"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "성"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "휴대폰 번호"; - -/* password */ -"LitecoinCard.Registration.password" = "암호"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "주 / 도"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "ID 유형"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "비워 둘 수 없습니다."; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "잘못된 이메일 주소"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "휴대 전화 번호는 10 자리 이상이어야합니다."; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "휴대폰 번호가 필요합니다."; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "비밀번호는 6 자 이상이어야합니다."; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "비밀번호는 6 자 이상이어야하며 최소 1 개의 문자와 1 개의 숫자가 있어야합니다."; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "비밀번호가 필요합니다"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "필수 필드"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "우편 번호"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "등록에 문제가 있습니다. 데이터를 확인하고 다시 시도하십시오."; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "등록하셨습니다! 이메일을 확인하고 확인하십시오. 그런 다음 다시 로그인하십시오."; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "암호를 재설정"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "라이트 코인 카드 계정과 연결된 이메일 주소를 입력하고 재설정 지침이있는 이메일을 찾으십시오."; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "카드로 이체"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "라이트월렛으로 이체"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "이체 지갑 선택:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "주소"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "균형 Litewallet"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "슬라이드하여 송금 금액 설정"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "전송 시작"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "옮기다"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "오류가있었습니다. 2FA를 '사용'으로 전환하고 이메일로 전송된 코드를 입력한 후 다시 시도하십시오."; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA 비활성화"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA 활성화"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "https://litecoin.dashboard.getblockcard.com/password/forgot을 방문하여 암호를 재설정하십시오."; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -628,20 +457,23 @@ /* Node Selector view title */ "NodeSelector.title" = "Litecoin 노드"; -/* Notifications will be added later but users can set their preferences now, which is why we specify "in the future". */ -"Notifications.body" = "알림을 켜두시면, 향후 브레드의 특별 메시지를 수신할 수 있습니다."; +/* "Email address label" */ +"Notifications.emailLabel" = "이메일 주소"; -/* Push notifications toggle switch label */ -"Notifications.label" = "푸시 알림"; +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = "여기에 입력하십시오"; -/* Push notifications are off label */ -"Notifications.off" = "Off"; +/* "Email title" */ +"Notifications.emailTitle" = "놓치지 마세요!"; -/* Push notifications are on label */ -"Notifications.on" = "On"; +/* "Language preference label" */ +"Notifications.languagePreference" = "선호하는 언어:"; -/* Push notifications settings view title label */ -"Notifications.title" = "알림"; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = "업데이트 및 컨테스트에 대해 들어 보려면 가입하십시오."; + +/* Signup cancel */ +"Notifications.signupCancel" = "고맙지 만 사양 할게."; /* Bad Payment request alert title */ "PaymentProtocol.Errors.badPaymentRequest" = "틀린 결제 요청"; @@ -901,9 +733,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "라이트 코인 주소를 입력하세요"; -/* Network Fee: $0.01 */ -"Send.fee" = "네트워크 비용: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "수수료: %1$@"; @@ -931,6 +760,9 @@ /* Loading request activity view message */ "Send.loadingRequest" = "요청 불러오는 중"; +/* Network */ +"Send.networkFee" = "회로망"; + /* Empty address alert message */ "Send.noAddress" = "수신인의 주소를 입력하세요."; @@ -952,6 +784,9 @@ /* Send button label (the action, "press here to send") */ "Send.sendLabel" = "보내기"; +/* Service */ +"Send.serviceFee" = "서비스"; + /* Send screen title (as in "this is the screen for sending money") */ "Send.title" = "보내기"; @@ -1455,6 +1290,3 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%2$d의 %1$d"; - -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = ""; diff --git a/litewallet/Strings/nl.lproj/Localizable.strings b/litewallet/Strings/nl.lproj/Localizable.strings index 7a162232a..b8d2e9ca8 100755 --- a/litewallet/Strings/nl.lproj/Localizable.strings +++ b/litewallet/Strings/nl.lproj/Localizable.strings @@ -166,9 +166,6 @@ /* Amount to Donate: ($1.00) */ "Confirmation.donateLabel" = "Te doneren bedrag:"; -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = "Netwerkkosten:"; - /* eg. Processing with Donation time: This transaction will take 10-30 minutes to process. */ "Confirmation.processingAndDonationTime" = "Verwerkingstijd: het verwerken van deze transacties duurt %1$@ minuten."; @@ -202,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "Woord #%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "vanaf"; - /* No comment provided by engineer. */ "Copy" = "Kopiëren"; @@ -379,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "Wissen"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "KAART"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "Kaartsaldo"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "Uitloggen en 2FA inschakelen om overdrachten toe te staan"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "- Registratie en inloggen\n- Beschikbaar kaartsaldo\n- Wachtwoord opnieuw instellen\n- Geen overdracht naar Litewallet\n- Alleen in de USA."; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "Litecoin Card heeft momenteel beperkte functionaliteit in Litewallet."; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "Bezoek litecoin.getblockcard.com voor volledige toegang"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Litecoin Card Beta"; - -/* Enter code */ -"LitecoinCard.enterCode" = "Voer code in"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "Voer de code in die onlangs naar uw Litecoin Card-account is verzonden."; - -/* Failed Login */ -"LitecoinCard.failed.login" = "Inloggen mislukt"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "Wachtwoord vergeten?"; - -/* Login */ -"LitecoinCard.login" = "Log in"; - -/* Logout */ -"LitecoinCard.logout" = "Uitloggen"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "Litecoin-kaart"; - -/* Register */ -"LitecoinCard.registerCard" = "Registreren"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "Registreer voor Litecoin Card"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "Gebruiker registreren ..."; - -/* address */ -"LitecoinCard.Registration.address" = "Woonadres"; - -/* city */ -"LitecoinCard.Registration.city" = "stad"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "bevestig wachtwoord"; - -/* country */ -"LitecoinCard.Registration.country" = "Land"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "Voornaam"; - -/* identification */ -"LitecoinCard.Registration.identification" = "Identificatie"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "Ken uw klant ID Type"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "ID Type"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "achternaam"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "Mobiele nummer"; - -/* password */ -"LitecoinCard.Registration.password" = "wachtwoord"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "Staat / Provincie"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "E-mail"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "Mag niet leeg zijn"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "Ongeldig e-mailadres"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "Het mobiele nummer moet minimaal 10 cijfers hebben"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "Mobiel nummer is vereist"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "Wachtwoord moet minimaal 6 tekens bevatten"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "Wachtwoord moet uit meer dan 6 tekens bestaan, met minimaal één teken en één numeriek teken"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "Een wachtwoord is verplicht"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "verplicht veld"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "Postcode"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "Er is een probleem opgetreden met uw registratie. Controleer uw gegevens en probeer het opnieuw."; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "U bent geregistreerd! Controleer en bevestig uw e-mail. Kom dan terug om in te loggen."; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "Wachtwoord opnieuw instellen"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "Voer het e-mailadres in dat is gekoppeld aan uw Litecoin Card-account en zoek een e-mail met resetinstructies."; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "Overboeken naar kaart"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "Overzetten naar Litewallet"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "Kies de overmakende portemonnee:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "Adres"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "Litewallet-saldo"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "Schuif om het overboekingsbedrag in te stellen"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "Overdracht starten"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "Overdracht"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "Er is een fout opgetreden. Zet a.u.b. 2FA op *Enabled*, voer de code per e-mail in en probeer het opnieuw."; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA niet ingeschakeld"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA ingeschakeld"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "Ga naar https://litecoin.dashboard.getblockcard.com/password/forgot om uw wachtwoord te resetten."; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -1420,20 +1246,23 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%1$d van %2$d"; -/* Push notifications settings view body */ -"Notifications.body" = ""; +/* "Email address label" */ +"Notifications.emailLabel" = ""; -/* Push notifications toggle switch label */ -"Notifications.label" = ""; +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = ""; -/* Push notifications are off label */ -"Notifications.off" = ""; +/* "Email title" */ +"Notifications.emailTitle" = ""; -/* Push notifications are on label */ -"Notifications.on" = ""; +/* "Language preference label" */ +"Notifications.languagePreference" = ""; -/* Push notifications settings view title label */ -"Notifications.title" = ""; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = ""; + +/* Signup cancel */ +"Notifications.signupCancel" = ""; /* Fee: $0.01 */ "Send.bareFee" = ""; @@ -1441,6 +1270,12 @@ /* Fees Blank: */ "Send.feeBlank" = ""; +/* Network */ +"Send.networkFee" = ""; + +/* Service */ +"Send.serviceFee" = ""; + /* domain */ "Send.UnstoppableDomains.domain" = ""; diff --git a/litewallet/Strings/pt.lproj/Localizable.strings b/litewallet/Strings/pt.lproj/Localizable.strings index f6cb4831a..ffb427769 100755 --- a/litewallet/Strings/pt.lproj/Localizable.strings +++ b/litewallet/Strings/pt.lproj/Localizable.strings @@ -200,9 +200,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "Palavra #%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "as of"; - /* No comment provided by engineer. */ "Copy" = "Copy"; @@ -377,174 +374,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "Apagar"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "CARTÃO"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "Saldo do cartão"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "Sair e habilitar 2FA para permitir transferências"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "- Registro e login\n- Saldo do cartão disponível\n- Redefinir senha\n- Sem transferência para Litewallet\n- Somente nos EUA"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "O cartão Litecoin atualmente tem funcionalidade limitada no Litewallet."; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "Visite litecoin.getblockcard.com para acesso total"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Litecoin Card Beta"; - -/* Enter code */ -"LitecoinCard.enterCode" = "Coloque o código"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "Digite o código que foi enviado recentemente para o e-mail da sua conta do cartão Litecoin."; - -/* Failed Login */ -"LitecoinCard.failed.login" = "Falha na autenticação"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "Esqueceu a senha?"; - -/* Login */ -"LitecoinCard.login" = "Conecte-se"; - -/* Logout */ -"LitecoinCard.logout" = "Sair"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "Litecoin Card"; - -/* Register */ -"LitecoinCard.registerCard" = "Registro"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "Registre-se para Cartão Litecoin"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "Registrando usuário ..."; - -/* address */ -"LitecoinCard.Registration.address" = "Endereço"; - -/* city */ -"LitecoinCard.Registration.city" = "Cidade"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "Confirme a Senha"; - -/* country */ -"LitecoinCard.Registration.country" = "País"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "primeiro nome"; - -/* identification */ -"LitecoinCard.Registration.identification" = "Identificação"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "Conheça seu cliente Tipo de identificação"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "Tipo de ID"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "último nome"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "Número de celular"; - -/* password */ -"LitecoinCard.Registration.password" = "senha"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "Estado / Província"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "E-mail"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "Não deve estar vazio"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "Endereço de email invalido"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "O número do celular deve ter pelo menos 10 dígitos"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "O número do celular é obrigatório"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "A senha deve ter pelo menos 6 caracteres"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "A senha deve ter mais de 6 caracteres, com pelo menos um caractere e um caractere numérico"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "Senha requerida"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "Campo obrigatório"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "Código postal"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "Ocorreu um problema com seu registro. Verifique seus dados e tente novamente."; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "Você se inscreveu! Verifique e confirme seu e-mail. Em seguida, volte para fazer o login."; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "Redefinir senha"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "Digite o endereço de e-mail associado à sua conta do cartão Litecoin e procure um e-mail com as instruções de reinicialização."; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "Transferir para cartão"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "Transferir para Litewallet"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "Escolha a carteira de transferência:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "Endereço"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "Litewallet balance"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "Deslize para definir o valor da transferência"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "Iniciar transferência"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "Transferir"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "Havia um erro. Alterne 2FA para *Ativado*, digite o código enviado por e-mail e tente novamente."; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA não habilitado"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA habilitado"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "Visite https://litecoin.dashboard.getblockcard.com/password/forgot para redefinir a sua palavra-passe."; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -629,20 +458,23 @@ /* Node Selector view title */ "NodeSelector.title" = "Nós de Litecoin"; -/* Notifications will be added later but users can set their preferences now, which is why we specify "in the future". */ -"Notifications.body" = "Ative as notificações para receber mensagens especiais da Litewallet no futuro."; +/* "Email address label" */ +"Notifications.emailLabel" = "Endereço de email"; -/* Push notifications toggle switch label */ -"Notifications.label" = "Notificações Push"; +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = "Entre aqui"; -/* Push notifications are off label */ -"Notifications.off" = "Desligado"; +/* "Email title" */ +"Notifications.emailTitle" = "Não perca nada!"; -/* Push notifications are on label */ -"Notifications.on" = "Ligado"; +/* "Language preference label" */ +"Notifications.languagePreference" = "Língua preferida:"; -/* Push notifications settings view title label */ -"Notifications.title" = "Notificações"; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = "Inscreva -se para ouvir sobre atualizações e concursos."; + +/* Signup cancel */ +"Notifications.signupCancel" = "Não, obrigado."; /* Bad Payment request alert title */ "PaymentProtocol.Errors.badPaymentRequest" = "Pedido de Pagamento Inválido"; @@ -902,9 +734,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Introduzir um endereço Litecoin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Taxa de rede: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "Tarifas: %1$@"; @@ -932,6 +761,9 @@ /* Loading request activity view message */ "Send.loadingRequest" = "A carregar o pedido"; +/* Network */ +"Send.networkFee" = "Rede"; + /* Empty address alert message */ "Send.noAddress" = "Por favor, introduza o endereço do destinatário."; @@ -953,6 +785,9 @@ /* Send button label (the action, "press here to send") */ "Send.sendLabel" = "Enviar"; +/* Service */ +"Send.serviceFee" = "Serviço"; + /* Send screen title (as in "this is the screen for sending money") */ "Send.title" = "Enviar"; @@ -1456,6 +1291,3 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%1$d de %2$d"; - -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = ""; diff --git a/litewallet/Strings/ru.lproj/Localizable.strings b/litewallet/Strings/ru.lproj/Localizable.strings index 90e31de89..4b8192767 100755 --- a/litewallet/Strings/ru.lproj/Localizable.strings +++ b/litewallet/Strings/ru.lproj/Localizable.strings @@ -199,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "Слово #%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "по состоянию на"; - /* No comment provided by engineer. */ "Copy" = "копия"; @@ -376,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "Очистить"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "КАРТА"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "Баланс карты"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "Выйдите из системы и включите 2FA, чтобы разрешить переводы"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "- Регистрация и вход\n- Доступный баланс карты\n- Сброс пароля\n- Без перевода на Litewallet\n- Только США"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "Litecoin Card в настоящее время имеет ограниченную функциональность в Litewallet."; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "Посетите litecoin.getblockcard.com для полного доступа"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Бета-версия карты Litecoin"; - -/* Enter code */ -"LitecoinCard.enterCode" = "Введите код"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "Введите код, который недавно был отправлен на электронную почту вашей учетной записи Litecoin Card."; - -/* Failed Login */ -"LitecoinCard.failed.login" = "Ошибка входа"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "Забыли пароль?"; - -/* Login */ -"LitecoinCard.login" = "Авторизоваться"; - -/* Logout */ -"LitecoinCard.logout" = "Выйти"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "Карта Litecoin"; - -/* Register */ -"LitecoinCard.registerCard" = "регистр"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "Зарегистрируйтесь для получения карты Litecoin"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "Регистрация пользователя ..."; - -/* address */ -"LitecoinCard.Registration.address" = "Адрес улицы"; - -/* city */ -"LitecoinCard.Registration.city" = "город"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "Подтвердите Пароль"; - -/* country */ -"LitecoinCard.Registration.country" = "Страна"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "Имя"; - -/* identification */ -"LitecoinCard.Registration.identification" = "Идентификация"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "Знай своего клиента Тип ID"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "Тип ID"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "фамилия"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "Номер мобильного"; - -/* password */ -"LitecoinCard.Registration.password" = "пароль"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "Штат / провинция"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "Эл. адрес"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "Не должно быть пустым"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "Неверный адрес электронной почты"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "Номер мобильного телефона должен состоять не менее чем из 10 цифр."; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "Требуется номер мобильного телефона"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "Пароль должен состоять не менее чем из 6 символов."; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "Пароль должен содержать более 6 символов, по крайней мере, один символ и один цифровой символ."; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "Необходим пароль"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "Обязательное поле"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "Почтовый индекс"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "При регистрации возникла проблема. Пожалуйста, проверьте свои данные и попробуйте еще раз."; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "Вы зарегистрировались! Пожалуйста, проверьте и подтвердите свою электронную почту. Затем вернитесь к логину."; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "Сброс пароля"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "Введите адрес электронной почты, связанный с вашей учетной записью Litecoin Card, и найдите письмо с инструкциями по сбросу."; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "Перевод на карту"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "Перевод в Litewallet"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "Выберите кошелек для перевода:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "Адрес"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "Баланс Litewallet"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "Сдвиньте, чтобы установить сумму перевода"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "Начать перевод"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "Передача"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "Это была ошибка. Переведите переключатель 2FA в положение *Включено*, введите полученный по электронной почте код и повторите попытку."; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA не включена"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA включен"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "Посетите https://litecoin.dashboard.getblockcard.com/password/forgot, чтобы сбросить пароль."; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -626,22 +455,25 @@ "NodeSelector.statusLabel" = "Состояние подключения узла"; /* Node Selector view title */ -"NodeSelector.title" = "Биткойн-узлы"; +"NodeSelector.title" = "Узлы Лайткоин"; -/* Notifications will be added later but users can set their preferences now, which is why we specify "in the future". */ -"Notifications.body" = "Включите уведомления, чтобы в будущем получать от Litewallet важные сообщения."; +/* "Email address label" */ +"Notifications.emailLabel" = "Адрес электронной почты"; -/* Push notifications toggle switch label */ -"Notifications.label" = "Push-уведомления"; +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = "Вход здесь"; -/* Push notifications are off label */ -"Notifications.off" = "Отключено"; +/* "Email title" */ +"Notifications.emailTitle" = "Не пропустите ничего!"; -/* Push notifications are on label */ -"Notifications.on" = "Включено"; +/* "Language preference label" */ +"Notifications.languagePreference" = "Предпочтительный язык:"; -/* Push notifications settings view title label */ -"Notifications.title" = "Уведомления"; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = "Зарегистрируйтесь, чтобы услышать об обновлениях и конкурсах."; + +/* Signup cancel */ +"Notifications.signupCancel" = "Нет, спасибо."; /* Bad Payment request alert title */ "PaymentProtocol.Errors.badPaymentRequest" = "Неверный запрос на совершение платежа"; @@ -901,9 +733,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Введите адрес Litecoin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Комиссия сети: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "Сборы: %1$@"; @@ -931,6 +760,9 @@ /* Loading request activity view message */ "Send.loadingRequest" = "Загрузка запроса"; +/* Network */ +"Send.networkFee" = "Сеть"; + /* Empty address alert message */ "Send.noAddress" = "Пожалуйста, введите адрес получателя."; @@ -952,6 +784,9 @@ /* Send button label (the action, "press here to send") */ "Send.sendLabel" = "Отправить"; +/* Service */ +"Send.serviceFee" = "Услуга"; + /* Send screen title (as in "this is the screen for sending money") */ "Send.title" = "Отправить"; @@ -1455,6 +1290,3 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%1$d из %2$d"; - -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = ""; diff --git a/litewallet/Strings/sv.lproj/Localizable.strings b/litewallet/Strings/sv.lproj/Localizable.strings index 9e0085c6c..96bd3b45b 100755 --- a/litewallet/Strings/sv.lproj/Localizable.strings +++ b/litewallet/Strings/sv.lproj/Localizable.strings @@ -166,9 +166,6 @@ /* Amount to Donate: ($1.00) */ "Confirmation.donateLabel" = "Belopp att donera:"; -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = "Nätverks-avgift"; - /* eg. Processing with Donation time: This transaction will take 10-30 minutes to process. */ "Confirmation.processingAndDonationTime" = "Bearbetningstid: Dessa transaktioner tar %1$@ minuter att behandla."; @@ -202,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "Ord #%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "från och med"; - /* No comment provided by engineer. */ "Copy" = "Kopia"; @@ -379,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "Töm"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "KORT"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "Kortbalans"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "Logga ut och aktivera 2FA för att tillåta överföringar"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "- Registrering & inloggning\n- Tillgängligt kortsaldo\n- Återställ lösenord\n- Ingen överföring till Litewallet\n- Endast USA"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "Litecoin Card har för närvarande begränsad funktionalitet i Litewallet."; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "Besök litecoin.getblockcard.com för fullständig åtkomst"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Litecoin-kort Beta"; - -/* Enter code */ -"LitecoinCard.enterCode" = "Ange kod"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "Ange koden som nyligen skickades till ditt Litecoin -kortkontots e -postadress."; - -/* Failed Login */ -"LitecoinCard.failed.login" = "Inloggningen misslyckades"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "Glömt ditt lösenord?"; - -/* Login */ -"LitecoinCard.login" = "Logga in"; - -/* Logout */ -"LitecoinCard.logout" = "Logga ut"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "Litecoin -kort"; - -/* Register */ -"LitecoinCard.registerCard" = "Registrera"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "Registrera dig för Litecoin-kort"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "Registrerar användare ..."; - -/* address */ -"LitecoinCard.Registration.address" = "Gatuadress"; - -/* city */ -"LitecoinCard.Registration.city" = "Stad"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "bekräfta lösenord"; - -/* country */ -"LitecoinCard.Registration.country" = "Land"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "förnamn"; - -/* identification */ -"LitecoinCard.Registration.identification" = "Identifiering"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "Känn din kund ID-typ"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "ID-typ"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "efternamn"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "Mobilnummer"; - -/* password */ -"LitecoinCard.Registration.password" = "Lösenord"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "Stat / provins"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "E-mail"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "Får inte vara tom"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "Ogiltig e-postadress"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "Mobilnumret måste innehålla minst tio siffror"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "Mobilnummer krävs"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "Lösenordet måste innehålla minst sex tecken"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "Lösenordet måste innehålla mer än sex tecken, med minst ett tecken och ett numeriskt tecken"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "Lösenord krävs"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "obligatoriskt fält"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "Postnummer"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "Det uppstod ett problem med din registrering.\nKontrollera dina uppgifter och försök igen."; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "Du har registrerat dig! Kontrollera och bekräfta din e-post.\nKom sedan tillbaka för att logga in."; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "Återställ lösenord"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "Ange e-postadressen som är kopplad till ditt Litecoin-kortkonto och leta efter ett e-postmeddelande med återställningsinstruktioner."; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "Överför till kort"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "Överför till Litewallet"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "Välj den överförande plånboken:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "Adress"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "Litewallet -balans"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "Skjut för att ställa in överföringsbelopp"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "Starta överföringen"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "Överföra"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "Det var ett problem. Växla 2FA till *Enabled *, ange den mejlade koden och försök igen."; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA Ej aktiverat"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA aktiverat"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "Besök https://litecoin.dashboard.getblockcard.com/password/forgot för att återställa ditt lösenord."; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -1420,20 +1246,23 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%1$d av %2$d"; -/* Push notifications settings view body */ -"Notifications.body" = ""; +/* "Email address label" */ +"Notifications.emailLabel" = ""; -/* Push notifications toggle switch label */ -"Notifications.label" = ""; +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = ""; -/* Push notifications are off label */ -"Notifications.off" = ""; +/* "Email title" */ +"Notifications.emailTitle" = ""; -/* Push notifications are on label */ -"Notifications.on" = ""; +/* "Language preference label" */ +"Notifications.languagePreference" = ""; -/* Push notifications settings view title label */ -"Notifications.title" = ""; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = ""; + +/* Signup cancel */ +"Notifications.signupCancel" = ""; /* Fee: $0.01 */ "Send.bareFee" = ""; @@ -1441,6 +1270,12 @@ /* Fees Blank: */ "Send.feeBlank" = ""; +/* Network */ +"Send.networkFee" = ""; + +/* Service */ +"Send.serviceFee" = ""; + /* domain */ "Send.UnstoppableDomains.domain" = ""; diff --git a/litewallet/Strings/tr.lproj/Localizable.strings b/litewallet/Strings/tr.lproj/Localizable.strings index 1d029e0be..a3e6b0e09 100644 --- a/litewallet/Strings/tr.lproj/Localizable.strings +++ b/litewallet/Strings/tr.lproj/Localizable.strings @@ -166,9 +166,6 @@ /* Amount to Donate: ($1.00) */ "Confirmation.donateLabel" = "Bağışlanacak Miktar:"; -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = "Ağ Ücreti:"; - /* eg. Processing with Donation time: This transaction will take 10-30 minutes to process. */ "Confirmation.processingAndDonationTime" = "İşlem süresi: Bu işlemlerin işlenmesi %1$@ dakika sürecektir."; @@ -202,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "Kelime #%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "itibariyle"; - /* Copy. */ "Copy" = "Kopyala"; @@ -379,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "Sil"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "Kart"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "Kart bakiyesi"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "Çıkış Yap ve Transferlere izin vermek için 2FA'yı Etkinleştir"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "- Kayıt ve Giriş \n- Kullanılabilir kart bakiyesi \n- Parolayı sıfırla \n- Litewallet'e aktarım yok \n- Yalnızca ABD"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "Litecoin Kartı şu anda Litewallet'te sınırlı işlevselliğe sahiptir."; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "Tam erişim için litecoin.getblockcard.com adresini ziyaret edin"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Litecoin Kartı Beta"; - -/* Enter code */ -"LitecoinCard.enterCode" = "Enter Code"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "Enter the code that was recently sent to your Litecoin Card account email."; - -/* Failed Login */ -"LitecoinCard.failed.login" = "Giriş başarısız oldu"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "Parolanızı mı unuttunuz?"; - -/* Login */ -"LitecoinCard.login" = "Giriş Yap"; - -/* Logout */ -"LitecoinCard.logout" = "Oturumu Kapat"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "Litecoin Card"; - -/* Register */ -"LitecoinCard.registerCard" = "Kayıt Ol"; - -/* Litecoin card */ -"LitecoinCard.registerCardPhrase" = "Litecoin Kartı için Kaydol"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "Kullanıcı kaydediliyor ..."; - -/* address */ -"LitecoinCard.Registration.address" = "Adres"; - -/* city */ -"LitecoinCard.Registration.city" = "Şehir"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "Parolayı onayla"; - -/* country */ -"LitecoinCard.Registration.country" = "Ülke"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "Adı"; - -/* identification */ -"LitecoinCard.Registration.identification" = "Kimlik"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "Kimlik Numarası"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "Kimlik Türü"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "Soyadı"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "Cep telefonu numarası"; - -/* password */ -"LitecoinCard.Registration.password" = "Parola"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "Eyalet / İl"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "E-posta (Kullanıcı Adı)"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "Boş olmamalıdır"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "Geçersiz E-posta Adresi"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "Cep telefonu numarası en az 10 basamaklı olmalıdır"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "Cep telefonu numarası gerekli"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "Parola en az 6 karakter içermelidir"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "Parola, en az bir karakter ve bir sayısal karakter içeren 6 karakterden fazla olmalıdır"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "Parola Gerekli"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "Gerekli alan"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "Posta Kodu / Posta Kodu"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "Kaydınız ile ilgili bir sorun oluştu. \nLütfen verilerinizi kontrol edin ve tekrar deneyin."; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "Kayıt oldunuz! \nLütfen e-postanızı kontrol edin ve onaylayın. Sonra tekrar giriş yapın."; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "Parolayı sıfırla"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "Litecoin Kart hesabınızla ilişkili e-posta adresini girin ve sıfırlama talimatlarını içeren bir e-posta arayın."; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "Karta Aktarma"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "Litewallet'a aktarın"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "Aktarılan cüzdanı seçin:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "Adres"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "Litewallet bakiyesi"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "Transfer miktarını ayarlamak için kaydırın"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "Aktarımı başlat"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "Aktar"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "Bir hata oluştu. Lütfen 2FA'yı *Etkin* olarak değiştirin, e-postayla gönderilen kodu girin ve tekrar deneyin."; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA Not Enabled"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA Enabled"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "Parolanızı sıfırlamak için https://litecoin.dashboard.getblockcard.com/password/forgot\n adresini ziyaret edin."; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -631,20 +457,23 @@ /* Node Selector view title */ "NodeSelector.title" = "Litecoin Düğümleri"; -/* Push notifications settings view body */ -"Notifications.body" = "İleride Litewallet'ten özel mesajlar almak için bildirimleri açın."; +/* "Email address label" */ +"Notifications.emailLabel" = "E -posta adresi"; + +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = "Buraya Gir"; -/* Push notifications toggle switch label */ -"Notifications.label" = "Push Bildirimleri"; +/* "Email title" */ +"Notifications.emailTitle" = "Bir şey kaçırmayın!"; -/* Push notifications are off label */ -"Notifications.off" = "Kapalı"; +/* "Language preference label" */ +"Notifications.languagePreference" = "Tercih edilen dil:"; -/* Push notifications are on label */ -"Notifications.on" = "Açık"; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = "Güncellemeleri ve yarışmaları duymak için kaydolun."; -/* Push notifications settings view title label */ -"Notifications.title" = "Bildirimler"; +/* Signup cancel */ +"Notifications.signupCancel" = "Hayır, teşekkürler."; /* Bad Payment request alert title */ "PaymentProtocol.Errors.badPaymentRequest" = "Kötü Ödeme İsteği"; @@ -904,9 +733,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Bir Litecoin adresi girin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Ağ Ücreti:%1$@"; - /* Fees: $0.01*/ "Send.fee" = "Ücretler: %1$@"; @@ -934,6 +760,9 @@ /* Loading request activity view message */ "Send.loadingRequest" = "Yükleme İsteği"; +/* Network */ +"Send.networkFee" = "Ağ"; + /* Empty address alert message */ "Send.noAddress" = "Lütfen alıcının adresini girin."; @@ -955,6 +784,9 @@ /* Send button label */ "Send.sendLabel" = "Gönder"; +/* Service */ +"Send.serviceFee" = "Hizmet"; + /* Send modal title */ "Send.title" = "Gönder"; diff --git a/litewallet/Strings/uk.lproj/Localizable.strings b/litewallet/Strings/uk.lproj/Localizable.strings index 7aabeec00..048c8a959 100644 --- a/litewallet/Strings/uk.lproj/Localizable.strings +++ b/litewallet/Strings/uk.lproj/Localizable.strings @@ -166,9 +166,6 @@ /* Amount to Donate: ($1.00) */ "Confirmation.donateLabel" = "Сума для пожертвування:"; -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = "Плата за мережу:"; - /* eg. Processing with Donation time: This transaction will take 10-30 minutes to process. */ "Confirmation.processingAndDonationTime" = "Час обробки: обробка цих трансакцій займе %1$@ хвилин."; @@ -202,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "слово #%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "щодо"; - /* No comment provided by engineer. */ "Copy" = "Копія"; @@ -379,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "Витерти"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "Картка"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "Баланс картки"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "Вийдіть із системи та ввімкніть 2FA, щоб дозволити перекази"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "- Реєстрація та вхід\n- Доступний баланс картки\n- Скидання пароля\n- Немає переказу на Litewallet\n- Лише в США"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "Наразі Litecoin Card має обмежену функціональність у Litewallet."; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "Відвідайте litecoin.getblockcard.com для повного доступу"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Бета-версія картки Litecoin"; - -/* Enter code */ -"LitecoinCard.enterCode" = "Введіть код"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "Введіть код, який нещодавно був надісланий на електронну адресу вашого облікового запису Litecoin Card."; - -/* Failed Login */ -"LitecoinCard.failed.login" = "Помилка логіну"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "Забули пароль?"; - -/* Login */ -"LitecoinCard.login" = "Увійти"; - -/* Logout */ -"LitecoinCard.logout" = "Вийти"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "Картка Litecoin"; - -/* Register */ -"LitecoinCard.registerCard" = "Реєстрація"; - -/* Litecoin card */ -"LitecoinCard.registerCardPhrase" = "Зареєструйтеся на карту Litecoin"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "Реєстрація користувача..."; - -/* address */ -"LitecoinCard.Registration.address" = "Адреса"; - -/* city */ -"LitecoinCard.Registration.city" = "Місто"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "Підтвердьте пароль"; - -/* country */ -"LitecoinCard.Registration.country" = "Країна"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "Ім'я"; - -/* identification */ -"LitecoinCard.Registration.identification" = "Ідентифікація"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "Номер документа"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "Тип ідентифікатора"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "Прізвище"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "Номер мобільного"; - -/* password */ -"LitecoinCard.Registration.password" = "Пароль"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "штат / провінція"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "Електронна пошта (ім'я користувача)"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "Не має бути порожнім"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "Невірна адреса електронної пошти"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "Номер мобільного телефону має містити не менше 10 цифр"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "Необхідний номер мобільного телефону"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "Пароль має містити не менше 6 символів"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "Пароль має містити більше 6 символів, принаймні один символ і один символ цифри"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "Пароль обов'язковий"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "Обов'язкове поле"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "Поштовий індекс"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "Виникла проблема з вашою реєстрацією. \nПеревірте свої дані та повторіть спробу."; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "Ви зареєструвалися!\nПеревірте та підтвердьте свою електронну пошту. Потім поверніться до входу."; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "Скинути пароль"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "Введіть адресу електронної пошти, пов’язану з вашим обліковим записом картки Litecoin, і знайдіть лист із інструкціями для скидання."; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "Переказ на картку"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "Перевести на Litewallet"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "Виберіть гаманець для передачі:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "Адреса"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "спрощений баланс гаманця"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "Проведіть пальцем, щоб встановити суму переказу"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "Почати передачу"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "Передача"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "Це була помилка. Увімкніть 2FA на *Enabled*, введіть код, надісланий електронною поштою, і повторіть спробу."; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA не ввімкнено"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "2FA увімкнено"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "Відвідати https://litecoin.dashboard.getblockcard.com/password/forgot\nщоб скинути пароль."; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -631,21 +457,6 @@ /* Node Selector view title */ "NodeSelector.title" = "Вузли Litecoin"; -/* Push notifications settings view body */ -"Notifications.body" = "Увімкніть сповіщення, щоб у майбутньому отримувати спеціальні повідомлення від Litewallet."; - -/* Push notifications toggle switch label */ -"Notifications.label" = "Push-повідомлення"; - -/* Push notifications are off label */ -"Notifications.off" = "Вимкнено"; - -/* Push notifications are on label */ -"Notifications.on" = "Увімкнено"; - -/* Push notifications settings view title label */ -"Notifications.title" = "Сповіщення"; - /* Bad Payment request alert title */ "PaymentProtocol.Errors.badPaymentRequest" = "Поганий запит на оплату"; @@ -904,9 +715,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Введіть адресу Litecoin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Плата за мережу: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "Збори: %1$@"; @@ -1458,3 +1266,27 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%1$d з %2$d"; + +/* "Email address label" */ +"Notifications.emailLabel" = ""; + +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = ""; + +/* "Email title" */ +"Notifications.emailTitle" = ""; + +/* "Language preference label" */ +"Notifications.languagePreference" = ""; + +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = ""; + +/* Signup cancel */ +"Notifications.signupCancel" = ""; + +/* Network */ +"Send.networkFee" = ""; + +/* Service */ +"Send.serviceFee" = ""; diff --git a/litewallet/Strings/zh-Hans.lproj/Localizable.strings b/litewallet/Strings/zh-Hans.lproj/Localizable.strings index 3065013a7..65eadc5aa 100755 --- a/litewallet/Strings/zh-Hans.lproj/Localizable.strings +++ b/litewallet/Strings/zh-Hans.lproj/Localizable.strings @@ -199,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "单词#%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "作为"; - /* No comment provided by engineer. */ "Copy" = "复制"; @@ -376,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "擦除"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "卡"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "卡余额"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "注销并启用 2FA 以允许传输"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "-注册和登录\n-可用卡余额\n-重置密码\n-不能转移到Litewallet\n-仅限美国"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "Litecoin卡当前在Litewallet中具有有限的功能。"; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "访问litecoin.getblockcard.com以获取完全访问权限"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Litecoin卡测试版"; - -/* Enter code */ -"LitecoinCard.enterCode" = "输入代码"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "输入最近发送到您的莱特币卡帐户电子邮件的代码。"; - -/* Failed Login */ -"LitecoinCard.failed.login" = "登录失败"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "忘记密码?"; - -/* Login */ -"LitecoinCard.login" = "登录"; - -/* Logout */ -"LitecoinCard.logout" = "登出"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "莱特币卡"; - -/* Register */ -"LitecoinCard.registerCard" = "寄存器"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "注册莱特币卡"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "正在注册用户..."; - -/* address */ -"LitecoinCard.Registration.address" = "街道地址"; - -/* city */ -"LitecoinCard.Registration.city" = "市"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "确认密码"; - -/* country */ -"LitecoinCard.Registration.country" = "国家"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "名字"; - -/* identification */ -"LitecoinCard.Registration.identification" = "身份证明"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "了解你的顾客 证件类型"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "ID 유형"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "姓"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "手机号码"; - -/* password */ -"LitecoinCard.Registration.password" = "密码"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "州/省"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "ID 유형"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "不能为空"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "无效的邮件地址"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "手机号码必须至少有10位数字"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "手机号码为必填项"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "密码必须至少包含6个字符"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "密码必须超过6个字符,并且至少包含一个字符和一个数字字符"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "密码是必需的"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "必填项目"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "邮编"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "您的注册有问题。 请检查您的数据,然后重试。"; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "您已经注册! 请检查并确认您的电子邮件。 然后返回登录。"; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "重设密码"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "输入与您的Litecoin卡帐户关联的电子邮件地址,并查找包含重置说明的电子邮件。"; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "转入卡"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "转移到Litewallet"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "选择转账钱包:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "地址"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "轻钱包余额"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "滑动设置转账金额"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "开始传输"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "转移"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "有一个错误。 请将 2FA 切换为 *Enabled*,输入通过电子邮件发送的代码,然后重试。"; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA 未启用"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "启用 2FA"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "请访问 https://litecoin.dashboard.getblockcard.com/password/forgot 重置您的密码。"; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -628,20 +457,23 @@ /* Node Selector view title */ "NodeSelector.title" = "莱特币节点"; -/* Notifications will be added later but users can set their preferences now, which is why we specify "in the future". */ -"Notifications.body" = "打开推送通知,以便将来从 Litewallet 接收特殊消息。"; +/* "Email address label" */ +"Notifications.emailLabel" = "电子邮件地址"; -/* Push notifications toggle switch label */ -"Notifications.label" = "推送通知"; +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = "在这里输入"; -/* Push notifications are off label */ -"Notifications.off" = "关闭"; +/* "Email title" */ +"Notifications.emailTitle" = "不要错过一件事!"; -/* Push notifications are on label */ -"Notifications.on" = "开启"; +/* "Language preference label" */ +"Notifications.languagePreference" = "首选语言:"; -/* Push notifications settings view title label */ -"Notifications.title" = "通知"; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = "注册以了解更新和竞赛。"; + +/* Signup cancel */ +"Notifications.signupCancel" = "不,谢谢。"; /* Bad Payment request alert title */ "PaymentProtocol.Errors.badPaymentRequest" = "假的付款请求"; @@ -901,9 +733,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "输入一个Litecoin地址"; -/* Network Fee: $0.01 */ -"Send.fee" = "网络费:%1$@"; - /* Fees: $0.01*/ "Send.fee" = "费用: %1$@"; @@ -931,6 +760,9 @@ /* Loading request activity view message */ "Send.loadingRequest" = "加载请求中"; +/* Network */ +"Send.networkFee" = "网络"; + /* Empty address alert message */ "Send.noAddress" = "请输入收件人地址。"; @@ -952,6 +784,9 @@ /* Send button label (the action, "press here to send") */ "Send.sendLabel" = "发送"; +/* Service */ +"Send.serviceFee" = "服务"; + /* Send screen title (as in "this is the screen for sending money") */ "Send.title" = "发送"; @@ -1455,6 +1290,3 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%1$d / %2$d"; - -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = ""; diff --git a/litewallet/Strings/zh-Hant.lproj/Localizable.strings b/litewallet/Strings/zh-Hant.lproj/Localizable.strings index faae4d7bd..8ff9890be 100755 --- a/litewallet/Strings/zh-Hant.lproj/Localizable.strings +++ b/litewallet/Strings/zh-Hant.lproj/Localizable.strings @@ -199,9 +199,6 @@ /* Word label eg. Word #1, Word #2 */ "ConfirmPaperPhrase.word" = "字組 #%1$@"; -/* as of a time or date */ -"Conjunction.asOf" = "作為"; - /* No comment provided by engineer. */ "Copy" = "複製"; @@ -376,174 +373,6 @@ /* Wipe wallet button */ "JailbreakWarnings.wipe" = "抹除"; -/* Card Bar Item Title */ -"LitecoinCard.barItemTitle" = "卡"; - -/* Card balance */ -"LitecoinCard.cardBalance" = "卡餘額"; - -/* Message when 2FA is off and user is viewing the Card Balance */ -"LitecoinCard.cardBalanceOnlyDescription" = "註銷並啟用 2FA 以允許傳輸"; - -/* Features and limitations */ -"LitecoinCard.Disclaimer.bullets" = "-註冊和登錄\n-可用卡餘額\n-重置密碼\n-不能轉移到Litewallet\n-僅限美國"; - -/* Description of the status */ -"LitecoinCard.Disclaimer.description" = "Litecoin卡當前在Litewallet中具有有限的功能。"; - -/* Referral to the website */ -"LitecoinCard.Disclaimer.referral" = "訪問litecoin.getblockcard.com以獲取完全訪問權限"; - -/* Beta Testing Litecoin Card */ -"LitecoinCard.Disclaimer.title" = "Litecoin卡測試版"; - -/* Enter code */ -"LitecoinCard.enterCode" = "輸入代碼"; - -/* Enter code details */ -"LitecoinCard.enterCode.detail" = "輸入最近發送到您的萊特幣卡帳戶電子郵件的代碼。"; - -/* Failed Login */ -"LitecoinCard.failed.login" = "登錄失敗"; - -/* Forgot password? */ -"LitecoinCard.forgotPassword" = "忘記密碼?"; - -/* Login */ -"LitecoinCard.login" = "登錄"; - -/* Logout */ -"LitecoinCard.logout" = "登出"; - -/* Card Bar Item Title */ -"LitecoinCard.name" = "萊特幣卡"; - -/* Register */ -"LitecoinCard.registerCard" = "寄存器"; - -/* Register for Litecoin Card */ -"LitecoinCard.registerCardPhrase" = "註冊萊特幣卡"; - -/* Registering user... */ -"LitecoinCard.registering.user" = "正在註冊用戶..."; - -/* address */ -"LitecoinCard.Registration.address" = "街道地址"; - -/* city */ -"LitecoinCard.Registration.city" = "市"; - -/* confirm password */ -"LitecoinCard.Registration.confirmPassword" = "確認密碼"; - -/* country */ -"LitecoinCard.Registration.country" = "國家"; - -/* First name */ -"LitecoinCard.Registration.firstName" = "名字"; - -/* identification */ -"LitecoinCard.Registration.identification" = "身份證明"; - -/* kycIDNumber */ -"LitecoinCard.Registration.kycIDNumber" = "了解你的顧客 證件類型"; - -/* kycIDType */ -"LitecoinCard.Registration.kycIDType" = "證件類型"; - -/* SSN */ -"LitecoinCard.Registration.kycSSN" = "SSN"; - -/* Last name */ -"LitecoinCard.Registration.lastName" = "姓"; - -/* mobile number */ -"LitecoinCard.Registration.mobileNumber" = "手機號碼"; - -/* password */ -"LitecoinCard.Registration.password" = "密碼"; - -/* state province */ -"LitecoinCard.Registration.stateProvince" = "州/省"; - -/* registraition username / email */ -"LitecoinCard.Registration.usernameEmail" = "電子郵件"; - -/* must not be empty */ -"LitecoinCard.Registration.ValidationError.empty" = "必填項目"; - -/* Invalid email address */ -"LitecoinCard.Registration.ValidationError.invalidEmail" = "無效的郵件地址"; - -/* Mobile number 10 digits required */ -"LitecoinCard.Registration.ValidationError.numberDigitsRequired" = "手機號碼必須至少有10位數字"; - -/* Mobile number required */ -"LitecoinCard.Registration.ValidationError.numberRequired" = "手機號碼為必填項"; - -/* 6 Password characters required */ -"LitecoinCard.Registration.ValidationError.passwordCharacters" = "密碼必須至少包含6個字符"; - -/* Captial and numeric password characters required */ -"LitecoinCard.Registration.ValidationError.passwordComposition" = "密碼必須超過6個字符,並且至少包含一個字符和一個數字字符"; - -/* Password required */ -"LitecoinCard.Registration.ValidationError.passwordRequired" = "密碼是必需的"; - -/* Required field */ -"LitecoinCard.Registration.ValidationError.requiredField" = "不能為空"; - -/* zip post Code */ -"LitecoinCard.Registration.zipPostCode" = "郵編"; - -/* Registration failure */ -"LitecoinCard.registrationFailure" = "您的註冊有問題。 請檢查您的數據,然後重試。"; - -/* Registration success */ -"LitecoinCard.registrationSuccess" = "您已經註冊! 請檢查並確認您的電子郵件。 然後返回登錄。"; - -/* Reset Litecoin card password */ -"LitecoinCard.resetPassword" = "重設密碼"; - -/* Reset password detail */ -"LitecoinCard.resetPasswordDetail" = "輸入與您的Litecoin卡帳戶關聯的電子郵件地址,並查找包含重置說明的電子郵件。"; - -/* Transfer to card label */ -"LitecoinCard.Transfer.amountToCard" = "轉入卡"; - -/* Transfer to Litewallet label */ -"LitecoinCard.Transfer.amountToLitewallet" = "轉移到Litewallet"; - -/* Description of action */ -"LitecoinCard.Transfer.description" = "選擇轉賬錢包:"; - -/* Destination address label */ -"LitecoinCard.Transfer.destinationAddress" = "地址"; - -/* Litewallet balance label */ -"LitecoinCard.Transfer.litewalletBalance" = "輕錢包餘額"; - -/* Set transfer amount label */ -"LitecoinCard.Transfer.setAmount" = "滑動設置轉賬金額"; - -/* Start transfer label */ -"LitecoinCard.Transfer.startTransfer" = "開始傳輸"; - -/* Transfer title */ -"LitecoinCard.Transfer.title" = "轉移"; - -/* 2FA Error message */ -"LitecoinCard.twoFAErrorMessage" = "有一個錯誤。 請將 2FA 切換為 *Enabled*,輸入電子郵件代碼,然後重試。"; - -/* Message when 2FA is off */ -"LitecoinCard.twoFAOff" = "2FA 未啟用"; - -/* Message when 2FA is on */ -"LitecoinCard.twoFAOn" = "啟用 2FA"; - -/* Litecoin card visit */ -"LitecoinCard.visit.toReset" = "訪問https://litecoin.dashboard.getblockcard.com/password/forgot重設密碼。"; - /* Litewallet name */ "Litewallet.name" = "Litewallet"; @@ -628,20 +457,23 @@ /* Node Selector view title */ "NodeSelector.title" = "萊特幣節點"; -/* Notifications will be added later but users can set their preferences now, which is why we specify "in the future". */ -"Notifications.body" = "開啟通知,好在未來收到 Litewallet 的特別訊息。"; +/* "Email address label" */ +"Notifications.emailLabel" = "電子郵件地址"; -/* Push notifications toggle switch label */ -"Notifications.label" = "推播"; +/* "Email address placeholder" */ +"Notifications.emailPlaceholder" = "在這裡輸入"; -/* Push notifications are off label */ -"Notifications.off" = "關閉"; +/* "Email title" */ +"Notifications.emailTitle" = "不要錯過一件事!"; -/* Push notifications are on label */ -"Notifications.on" = "開啟"; +/* "Language preference label" */ +"Notifications.languagePreference" = "首選語言:"; -/* Push notifications settings view title label */ -"Notifications.title" = "通知"; +/* "Pitch to get user to sign up" */ +"Notifications.pitchMessage" = "註冊以了解更新和競賽。"; + +/* Signup cancel */ +"Notifications.signupCancel" = "不,謝謝。"; /* Bad Payment request alert title */ "PaymentProtocol.Errors.badPaymentRequest" = "不良的付款請求"; @@ -901,9 +733,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "輸入萊特幣地址"; -/* Network Fee: $0.01 */ -"Send.fee" = "網路費:%1$@"; - /* Fees: $0.01*/ "Send.fee" = "費用: %1$@"; @@ -931,6 +760,9 @@ /* Loading request activity view message */ "Send.loadingRequest" = "正在載入要求"; +/* Network */ +"Send.networkFee" = "網絡"; + /* Empty address alert message */ "Send.noAddress" = "請輸入收受者的地址。"; @@ -952,6 +784,9 @@ /* Send button label (the action, "press here to send") */ "Send.sendLabel" = "寄送"; +/* Service */ +"Send.serviceFee" = "服務"; + /* Send screen title (as in "this is the screen for sending money") */ "Send.title" = "寄送"; @@ -982,9 +817,6 @@ /* UDSystemError */ "Send.UnstoppableDomains.udSystemError" = "系統查找問題。 [錯誤:%2$d]"; -/* UDSystemError */ -"Send.UnstoppableDomains.udSystemError" = "系統查找問題。 [錯誤:%2$d]"; - /* Adress already used alert message - first part */ "Send.UsedAddress.firstLine" = "萊特幣位址專供一次性使用。"; @@ -1458,6 +1290,3 @@ /* 1 of 3 */ "WritePaperPhrase.step" = "%2$d 之 %1$d"; - -/* Network Fee: ($1.00) */ -"Confirmation.feeLabel" = ""; diff --git a/litewallet/TransactionModalView.swift b/litewallet/TransactionModalView.swift index bec7760f0..0dd622c46 100644 --- a/litewallet/TransactionModalView.swift +++ b/litewallet/TransactionModalView.swift @@ -25,7 +25,7 @@ struct TransactionModalView: View { var body: some View { VStack(spacing: 1.0) { HStack { - Text("Transaction Details") + Text(S.TransactionDetails.title.localize()) .font(Font(UIFont.barlowSemiBold(size: 18.0))) .foregroundColor(.white) .frame(minWidth: 0, maxWidth: .infinity) diff --git a/litewallet/ViewControllers/AmountViewController.swift b/litewallet/ViewControllers/AmountViewController.swift index ef3048d71..7cc886861 100644 --- a/litewallet/ViewControllers/AmountViewController.swift +++ b/litewallet/ViewControllers/AmountViewController.swift @@ -19,7 +19,7 @@ class AmountViewController: UIViewController, Trackable { private let bottomBorder = UIView(color: .secondaryShadow) private let cursor = BlinkingView(blinkColor: C.defaultTintColor) private let balanceLabel = UILabel() - private let feeLabel = UILabel() + private let feesLabel = UILabel() private let feeContainer = InViewAlert(type: .secondary) private let tapView = UIView() private let editFee = UIButton(type: .system) @@ -110,7 +110,7 @@ class AmountViewController: UIViewController, Trackable { view.addSubview(border) view.addSubview(cursor) view.addSubview(balanceLabel) - view.addSubview(feeLabel) + view.addSubview(feesLabel) view.addSubview(tapView) view.addSubview(amountLabel) view.addSubview(bottomBorder) @@ -140,7 +140,7 @@ class AmountViewController: UIViewController, Trackable { currencyToggle.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), ]) feeSelectorHeight = feeContainer.heightAnchor.constraint(equalToConstant: 0.0) - feeSelectorTop = feeContainer.topAnchor.constraint(equalTo: feeLabel.bottomAnchor, constant: 0.0) + feeSelectorTop = feeContainer.topAnchor.constraint(equalTo: feesLabel.bottomAnchor, constant: 0.0) feeContainer.constrain([ feeSelectorTop, @@ -161,9 +161,9 @@ class AmountViewController: UIViewController, Trackable { balanceLabel.leadingAnchor.constraint(equalTo: amountLabel.leadingAnchor), balanceLabel.topAnchor.constraint(equalTo: cursor.bottomAnchor, constant: 10.0), ]) - feeLabel.constrain([ - feeLabel.leadingAnchor.constraint(equalTo: balanceLabel.leadingAnchor), - feeLabel.topAnchor.constraint(equalTo: balanceLabel.bottomAnchor), + feesLabel.constrain([ + feesLabel.leadingAnchor.constraint(equalTo: balanceLabel.leadingAnchor), + feesLabel.topAnchor.constraint(equalTo: balanceLabel.bottomAnchor), ]) pinPadHeight = pinPad.view.heightAnchor.constraint(equalToConstant: 0.0) addChildViewController(pinPad, layout: { @@ -176,13 +176,13 @@ class AmountViewController: UIViewController, Trackable { ]) }) editFee.constrain([ - editFee.leadingAnchor.constraint(equalTo: feeLabel.trailingAnchor, constant: -8.0), - editFee.centerYAnchor.constraint(equalTo: feeLabel.centerYAnchor, constant: -1.0), + editFee.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0), + editFee.centerYAnchor.constraint(equalTo: feesLabel.centerYAnchor, constant: -1.0), editFee.widthAnchor.constraint(equalToConstant: 44.0), editFee.heightAnchor.constraint(equalToConstant: 44.0), ]) bottomBorder.constrain([ - bottomBorder.topAnchor.constraint(greaterThanOrEqualTo: currencyToggle.bottomAnchor, constant: C.padding[2]), + bottomBorder.topAnchor.constraint(greaterThanOrEqualTo: currencyToggle.bottomAnchor, constant: C.padding[3]), bottomBorder.leadingAnchor.constraint(equalTo: view.leadingAnchor), bottomBorder.bottomAnchor.constraint(equalTo: view.bottomAnchor), bottomBorder.trailingAnchor.constraint(equalTo: view.trailingAnchor), @@ -313,9 +313,9 @@ class AmountViewController: UIViewController, Trackable { } func updateBalanceLabel() { - if let (balance, fee) = balanceTextForAmount?(amount, selectedRate) { + if let (balance, fees) = balanceTextForAmount?(amount, selectedRate) { balanceLabel.attributedText = balance - feeLabel.attributedText = fee + feesLabel.attributedText = fees if let amount = amount, amount > 0, !isRequesting { editFee.isHidden = false } else { diff --git a/litewallet/ViewControllers/RootModals/SendViewController.swift b/litewallet/ViewControllers/RootModals/SendViewController.swift index 218370beb..8c184dee6 100644 --- a/litewallet/ViewControllers/RootModals/SendViewController.swift +++ b/litewallet/ViewControllers/RootModals/SendViewController.swift @@ -150,7 +150,7 @@ class SendViewController: UIViewController, Subscriber, ModalPresentable, Tracka // MARK: - AmountView Callbacks amountView.balanceTextForAmount = { [weak self] amount, rate in - self?.balanceTextForAmount(amount: amount, rate: rate) + self?.balanceTextForAmountWithFormattedFees(amount: amount, rate: rate) } amountView.didUpdateAmount = { [weak self] amount in @@ -207,45 +207,57 @@ class SendViewController: UIViewController, Subscriber, ModalPresentable, Tracka } } - private func balanceTextForAmount(amount: Satoshis?, rate: Rate?) -> (NSAttributedString?, NSAttributedString?) + private func balanceTextForAmountWithFormattedFees(amount: Satoshis?, rate: Rate?) -> (NSAttributedString?, NSAttributedString?) { + // DEV: KCW 12-FEB-24 + // The results of this output is doing double duty and the method is a nightmare. + // The parent view controller uses the numbers and the text is used in this View Controller + let balanceAmount = DisplayAmount(amount: Satoshis(rawValue: balance), state: store.state, selectedRate: rate, minimumFractionDigits: 2) let balanceText = balanceAmount.description let balanceOutput = String(format: S.Send.balance.localize(), balanceText) - var feeOutput = "" + var combinedFeesOutput = "" var balanceColor: UIColor = .grayTextTint + let balanceStyle = [ + NSAttributedString.Key.font: UIFont.customBody(size: 14.0), + NSAttributedString.Key.foregroundColor: balanceColor, + ] + /// Check the amount is greater than zero and if user is opting out of fees if let amount = amount, amount > 0 { let tieredOpsFee = tieredOpsFee(amount: amount.rawValue) - let totalAmountToCalculateFees = hasActivatedInlineFees ? (amount.rawValue + tieredOpsFee) : amount.rawValue + let totalAmountToCalculateFees = (amount.rawValue + tieredOpsFee) + + let networkFee = sender.feeForTx(amount: totalAmountToCalculateFees) - let fee = sender.feeForTx(amount: totalAmountToCalculateFees) + let networkFeeAmount = DisplayAmount(amount: Satoshis(rawValue: networkFee), + state: store.state, + selectedRate: rate, + minimumFractionDigits: 2).description - let feeAmountLabel = DisplayAmount(amount: Satoshis(rawValue: fee) + tieredOpsFee, + let serviceFeeAmount = DisplayAmount(amount: Satoshis(rawValue: tieredOpsFee), + state: store.state, + selectedRate: rate, + minimumFractionDigits: 2).description + + let totalFeeAmount = DisplayAmount(amount: Satoshis(rawValue: networkFee + tieredOpsFee), state: store.state, selectedRate: rate, - minimumFractionDigits: 2) + minimumFractionDigits: 2).description + let combinedfeeText = networkFeeAmount.description.replacingZeroFeeWithTenCents() + + serviceFeeAmount.description.replacingZeroFeeWithTenCents() + + totalFeeAmount.description.replacingZeroFeeWithTenCents() - let feeText = feeAmountLabel.description.replacingZeroFeeWithTenCents() - feeOutput = hasActivatedInlineFees ? String(format: S.Send.fee.localize(), feeText) : - String(format: S.Send.feeBlank.localize(), feeText) + combinedFeesOutput = "(\(S.Send.networkFee.localize()) + \(S.Send.serviceFee.localize())): \(networkFeeAmount) + \(serviceFeeAmount) = \(totalFeeAmount)" - if balance >= (fee + tieredOpsFee), amount.rawValue > (balance - (fee + tieredOpsFee)) { + if balance >= (networkFee + tieredOpsFee), amount.rawValue > (balance - (networkFee + tieredOpsFee)) { balanceColor = .litewalletOrange } } - let balanceStyle = [ - NSAttributedString.Key.font: UIFont.customBody(size: 14.0), - NSAttributedString.Key.foregroundColor: balanceColor, - ] - - let balanceAttributes: [NSAttributedString.Key: Any] = balanceStyle - let feeAttributes: [NSAttributedString.Key: Any] = balanceStyle - - return (NSAttributedString(string: balanceOutput, attributes: balanceAttributes), NSAttributedString(string: feeOutput, attributes: feeAttributes)) + return (NSAttributedString(string: balanceOutput, attributes: balanceStyle), NSAttributedString(string: combinedFeesOutput, attributes: balanceStyle)) } @objc private func pasteTapped() { diff --git a/litewallet/ViewControllers/WritePaperPhraseViewController.swift b/litewallet/ViewControllers/WritePaperPhraseViewController.swift index 383ab57e2..14218dbd8 100644 --- a/litewallet/ViewControllers/WritePaperPhraseViewController.swift +++ b/litewallet/ViewControllers/WritePaperPhraseViewController.swift @@ -30,8 +30,6 @@ class WritePaperPhraseViewController: UIViewController { } } - var lastWordSeen: (() -> Void)? - init(store: Store, walletManager: WalletManager, pin: String, callback: @escaping () -> Void) { self.store = store self.walletManager = walletManager diff --git a/litewallet/ViewModels/Transaction.swift b/litewallet/ViewModels/Transaction.swift index 9e02031ea..d20c8d2a1 100644 --- a/litewallet/ViewModels/Transaction.swift +++ b/litewallet/ViewModels/Transaction.swift @@ -21,10 +21,14 @@ class Transaction { self.kvStore = kvStore let fee = wallet.feeForTx(tx) ?? 0 - self.fee = fee + + let opsOutput = tx.outputs.filter { $0.updatedSwiftAddress == Partner.partnerKeyPath(name: .litewalletOps) }.first + guard let opsAmount = opsOutput?.amount else { return nil } + + self.fee = fee + opsAmount let amountReceived = wallet.amountReceivedFromTx(tx) - let amountSent = wallet.amountSentByTx(tx) + let amountSent = wallet.amountSentByTx(tx) - opsAmount if amountSent > 0, (amountReceived + fee) == amountSent { direction = .moved @@ -158,12 +162,10 @@ class Transaction { switch self.direction { case .sent: - guard let output = self - .tx.outputs.filter({ output in - !self.wallet.containsAddress(output.updatedSwiftAddress) - }) - .first - + let allOutputs = self.tx.outputs.filter { $0.updatedSwiftAddress != Partner.partnerKeyPath(name: .litewalletOps) } + guard let output = allOutputs.filter({ output in + !self.wallet.containsAddress(output.updatedSwiftAddress) + }).first else { LWAnalytics.logEventWithParameters(itemName: ._20200112_ERR) return nil diff --git a/litewallet/WalletCoordinator.swift b/litewallet/WalletCoordinator.swift index 83695f719..d77141399 100644 --- a/litewallet/WalletCoordinator.swift +++ b/litewallet/WalletCoordinator.swift @@ -120,12 +120,19 @@ class WalletCoordinator: Subscriber, Trackable { updateTimer?.invalidate() updateTimer = nil DispatchQueue.walletQueue.async { - guard let txRefs = self.walletManager.wallet?.transactions else { return } + guard let txRefs = self.walletManager.wallet?.transactions else { + let properties = ["error_message": "wallet_tx_refs_are_nil"] + LWAnalytics.logEventWithParameters(itemName: ._20200112_ERR, properties: properties) + return + } let transactions = self.makeTransactionViewModels(transactions: txRefs, walletManager: self.walletManager, kvStore: self.kvStore, rate: self.store.state.currentRate) if !transactions.isEmpty { DispatchQueue.main.async { self.store.perform(action: WalletChange.setTransactions(transactions)) } + } else { + let properties = ["transactions_info": "no_txs_found_in_wallet"] + LWAnalytics.logEventWithParameters(itemName: ._20240214_TI, properties: properties) } } } diff --git a/litewallet/de.lproj/Localizable.strings b/litewallet/de.lproj/Localizable.strings index dbb498fbc..b5ba77126 100755 --- a/litewallet/de.lproj/Localizable.strings +++ b/litewallet/de.lproj/Localizable.strings @@ -877,9 +877,6 @@ /* Fee: $0.01 */ "Send.bareFee" = "Gebühr: %1$@"; -/* Fee: $0.01 */ -"Send.bareFee" = "Gebühr: %1$@"; - /* Send Bar Item Title */ "Send.barItemTitle" = "SENDEN"; @@ -904,8 +901,8 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Geben Sie eine Litecoin-Adresse ein"; -/* Network Fee: $0.01 */ -"Send.fee" = "Netzwerkgebühr: %1$@"; +/* Fees: $0.01*/ +"Send.fee" = "Gebühren: %1$@"; /* Fees: $0.01*/ "Send.fee" = "Gebühren: %1$@"; @@ -919,6 +916,12 @@ /* Fees Blank: */ "Send.feeBlank" = "Gebühren:"; +/* Fees Blank: */ +"Send.feeBlank" = "Gebühren:"; + +/* Fees Blank: */ +"Send.feeBlank" = "Gebühren:"; + /* Payee identity not certified alert title. */ "Send.identityNotCertified" = "die Identität des Zahlungsempfängers ist nicht bestätigt."; diff --git a/litewallet/en.lproj/Localizable.strings b/litewallet/en.lproj/Localizable.strings index bb60fe4ef..4cc13379c 100644 --- a/litewallet/en.lproj/Localizable.strings +++ b/litewallet/en.lproj/Localizable.strings @@ -880,9 +880,6 @@ /* Fee: $0.01 */ "Send.bareFee" = "Fee: %1$@"; -/* Fee: $0.01 */ -"Send.bareFee" = "Fee: %1$@"; - /* Send Bar Item Title */ "Send.barItemTitle" = "SEND"; @@ -907,8 +904,8 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Enter a Litecoin address"; -/* Network Fee: $0.01 */ -"Send.fee" = "Network Fee: %1$@"; +/* Fees: $0.01*/ +"Send.fee" = "Fees: %1$@"; /* Fees: $0.01*/ "Send.fee" = "Fees: %1$@"; @@ -922,6 +919,9 @@ /* Fees Blank: */ "Send.feeBlank" = "Fees:"; +/* Fees Blank: */ +"Send.feeBlank" = "Fees:"; + /* Payee identity not certified alert title. */ "Send.identityNotCertified" = "Payee identity isn't certified."; diff --git a/litewallet/es.lproj/Localizable.strings b/litewallet/es.lproj/Localizable.strings index 22fe7027a..9dc4beea1 100755 --- a/litewallet/es.lproj/Localizable.strings +++ b/litewallet/es.lproj/Localizable.strings @@ -879,9 +879,6 @@ /* Fee: $0.01 */ "Send.bareFee" = "Tarifa: %1$@"; -/* Fee: $0.01 */ -"Send.bareFee" = "Tarifa: %1$@"; - /* Send Bar Item Title */ "Send.barItemTitle" = "ENVIAR"; @@ -906,21 +903,12 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Introduzca una dirección de Litecoin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Tarifa de red: %1$@"; - -/* Fees: $0.01*/ -"Send.fee" = "Honorarios: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "Honorarios: %1$@"; /* Fees Blank: */ "Send.feeBlank" = "Honorarios:"; -/* Fees Blank: */ -"Send.feeBlank" = "Honorarios:"; - /* Payee identity not certified alert title. */ "Send.identityNotCertified" = "La identidad del beneficiario no está certificada."; diff --git a/litewallet/fr.lproj/Localizable.strings b/litewallet/fr.lproj/Localizable.strings index f8a61a429..9ffdcb27d 100755 --- a/litewallet/fr.lproj/Localizable.strings +++ b/litewallet/fr.lproj/Localizable.strings @@ -877,9 +877,6 @@ /* Fee: $0.01 */ "Send.bareFee" = "Frais: %1$@"; -/* Fee: $0.01 */ -"Send.bareFee" = "Frais: %1$@"; - /* Send Bar Item Title */ "Send.barItemTitle" = "ENVOYER"; @@ -904,21 +901,12 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Entrez une adresse Litecoin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Frais de réseau : %1$@"; - -/* Fees: $0.01*/ -"Send.fee" = "Frais: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "Frais: %1$@"; /* Fees Blank: */ "Send.feeBlank" = "Frais:"; -/* Fees Blank: */ -"Send.feeBlank" = "Frais:"; - /* Payee identity not certified alert title. */ "Send.identityNotCertified" = "L'identité du bénéficiaire n'est pas certifiée."; diff --git a/litewallet/id.lproj/Localizable.strings b/litewallet/id.lproj/Localizable.strings index e309c3273..26291fa90 100644 --- a/litewallet/id.lproj/Localizable.strings +++ b/litewallet/id.lproj/Localizable.strings @@ -877,9 +877,6 @@ /* Fee: $0.01 */ "Send.bareFee" = "Biaya: %1$@"; -/* Fee: $0.01 */ -"Send.bareFee" = "Biaya: %1$@"; - /* Send Bar Item Title */ "Send.barItemTitle" = "KIRIM"; @@ -904,21 +901,12 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Masukkan alamat Litecoin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Biaya Jaringan: %1$@"; - -/* Fees: $0.01*/ -"Send.fee" = "Biaya: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "Biaya: %1$@"; /* Fees Blank: */ "Send.feeBlank" = "Biaya:"; -/* Fees Blank: */ -"Send.feeBlank" = "Biaya:"; - /* Payee identity not certified alert title. */ "Send.identityNotCertified" = "Identitas penerima pembayaran tidak disertifikasi."; diff --git a/litewallet/it.lproj/Localizable.strings b/litewallet/it.lproj/Localizable.strings index c8f629946..969ec4e6e 100755 --- a/litewallet/it.lproj/Localizable.strings +++ b/litewallet/it.lproj/Localizable.strings @@ -877,9 +877,6 @@ /* Fee: $0.01 */ "Send.bareFee" = "Tassa: %1$@"; -/* Fee: $0.01 */ -"Send.bareFee" = "Tassa: %1$@"; - /* Send Bar Item Title */ "Send.barItemTitle" = "SPEDIRE"; @@ -907,15 +904,6 @@ /* Network Fee: $0.01 */ "Send.fee" = "Spese network: %1$@"; -/* Fees: $0.01*/ -"Send.fee" = "Commissioni: %1$@"; - -/* Fees: $0.01*/ -"Send.fee" = "Commissioni: %1$@"; - -/* Fees Blank: */ -"Send.feeBlank" = "Commissioni:"; - /* Fees Blank: */ "Send.feeBlank" = "Commissioni:"; diff --git a/litewallet/ja.lproj/Localizable.strings b/litewallet/ja.lproj/Localizable.strings index df736eee6..5ffbb3afd 100755 --- a/litewallet/ja.lproj/Localizable.strings +++ b/litewallet/ja.lproj/Localizable.strings @@ -877,9 +877,6 @@ /* Fee: $0.01 */ "Send.bareFee" = "手数料: %1$@"; -/* Fee: $0.01 */ -"Send.bareFee" = "手数料: %1$@"; - /* Send Bar Item Title */ "Send.barItemTitle" = "送信"; @@ -904,21 +901,12 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Litecoinのアドレスを入力してください。"; -/* Network Fee: $0.01 */ -"Send.fee" = "ネットワーク手数料: %1$@"; - -/* Fees: $0.01*/ -"Send.fee" = "料金: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "料金: %1$@"; /* Fees Blank: */ "Send.feeBlank" = "料金:"; -/* Fees Blank: */ -"Send.feeBlank" = "料金:"; - /* Payee identity not certified alert title. */ "Send.identityNotCertified" = "受取人の本人確認が行われていません。"; diff --git a/litewallet/ko.lproj/Localizable.strings b/litewallet/ko.lproj/Localizable.strings index 8b95e98e9..f75cd34cd 100755 --- a/litewallet/ko.lproj/Localizable.strings +++ b/litewallet/ko.lproj/Localizable.strings @@ -877,9 +877,6 @@ /* Fee: $0.01 */ "Send.bareFee" = "요금: %1$@"; -/* Fee: $0.01 */ -"Send.bareFee" = "요금: %1$@"; - /* Send Bar Item Title */ "Send.barItemTitle" = "보내다"; @@ -904,21 +901,12 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "라이트 코인 주소를 입력하세요"; -/* Network Fee: $0.01 */ -"Send.fee" = "네트워크 비용: %1$@"; - -/* Fees: $0.01*/ -"Send.fee" = "수수료: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "수수료: %1$@"; /* Fees Blank: */ "Send.feeBlank" = "수수료:"; -/* Fees Blank: */ -"Send.feeBlank" = "수수료:"; - /* Payee identity not certified alert title. */ "Send.identityNotCertified" = "인증되지 않은 수취인입니다."; diff --git a/litewallet/pt.lproj/Localizable.strings b/litewallet/pt.lproj/Localizable.strings index 42669487a..76b6953b5 100755 --- a/litewallet/pt.lproj/Localizable.strings +++ b/litewallet/pt.lproj/Localizable.strings @@ -878,9 +878,6 @@ /* Fee: $0.01 */ "Send.bareFee" = "Taxa: %1$@"; -/* Fee: $0.01 */ -"Send.bareFee" = "Taxa: %1$@"; - /* Send Bar Item Title */ "Send.barItemTitle" = "SEND"; @@ -905,21 +902,12 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Introduzir um endereço Litecoin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Taxa de rede: %1$@"; - -/* Fees: $0.01*/ -"Send.fee" = "Tarifas: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "Tarifas: %1$@"; /* Fees Blank: */ "Send.feeBlank" = "Tarifas:"; -/* Fees Blank: */ -"Send.feeBlank" = "Tarifas:"; - /* Payee identity not certified alert title. */ "Send.identityNotCertified" = "A identidade do beneficiário não está certificada."; diff --git a/litewallet/ru.lproj/Localizable.strings b/litewallet/ru.lproj/Localizable.strings index 8eeb92211..b57e4445c 100755 --- a/litewallet/ru.lproj/Localizable.strings +++ b/litewallet/ru.lproj/Localizable.strings @@ -877,9 +877,6 @@ /* Fee: $0.01 */ "Send.bareFee" = "Платеж: %1$@"; -/* Fee: $0.01 */ -"Send.bareFee" = "Платеж: %1$@"; - /* Send Bar Item Title */ "Send.barItemTitle" = "ОТПРАВИТЬ"; @@ -904,21 +901,12 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Введите адрес Litecoin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Комиссия сети: %1$@"; - -/* Fees: $0.01*/ -"Send.fee" = "Сборы: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "Сборы: %1$@"; /* Fees Blank: */ "Send.feeBlank" = "Сборы:"; -/* Fees Blank: */ -"Send.feeBlank" = "Сборы:"; - /* Payee identity not certified alert title. */ "Send.identityNotCertified" = "Личность получателя платежа не сертифицирована."; diff --git a/litewallet/src/AnnounceUpdatesView.swift b/litewallet/src/AnnounceUpdatesView.swift new file mode 100644 index 000000000..3787f123c --- /dev/null +++ b/litewallet/src/AnnounceUpdatesView.swift @@ -0,0 +1,129 @@ +import PushNotifications +import SafariServices +import SwiftUI + +enum NavigateStart { + case create + case recover +} + +struct AnnounceUpdatesView: View { + @EnvironmentObject + var viewModel: StartViewModel + + @State + private var didComplete = false + + @State + private var languagePref: LanguageSelection = .English + + @FocusState private var isEmailFieldFocused: Bool + + @Binding + var didTapContinue: Bool + + let navigateStart: NavigateStart + + let paragraphFont: Font = .barlowSemiBold(size: 22.0) + let calloutFont: Font = .barlowLight(size: 12.0) + + let genericPad = 20.0 + let smallLabelPad = 15.0 + let buttonHeight = 44.0 + let pageHeight = 145.0 + let hugeFont = Font.barlowBold(size: 30.0) + let buttonLightFont: Font = .barlowLight(size: 20.0) + let buttonFont: Font = .barlowSemiBold(size: 20.0) + let appDelegate = UIApplication.shared.delegate as! AppDelegate + + init(navigateStart: NavigateStart, language: LanguageSelection, didTapContinue: Binding) { + self.navigateStart = navigateStart + languagePref = language + _didTapContinue = didTapContinue + } + + var body: some View { + GeometryReader { geometry in + + let width = geometry.size.width + let height = geometry.size.height + + ZStack { + Color.liteWalletDarkBlue.edgesIgnoringSafeArea(.all) + VStack { + Text(S.Notifications.emailTitle.localize()) + .font(hugeFont) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(idealHeight: buttonHeight) + .foregroundColor(.white) + .padding(.all, genericPad) + .padding(.top, height * 0.08) + + Text(S.Notifications.pitchMessage.localize()) + .font(buttonLightFont) + .kerning(0.3) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundColor(.white) + .padding(.all, genericPad) + .onTapGesture { + isEmailFieldFocused.toggle() + } + + SignupWebView(userAction: $didComplete, + urlString: C.signupURL) + .frame(width: width) + .frame(height: pageHeight) + .edgesIgnoringSafeArea(.all) + .padding(.bottom, smallLabelPad) + .onChange(of: didComplete) { updateValue in + if updateValue { + switch navigateStart { + case .create: + viewModel.didTapCreate!() + case .recover: + viewModel.didTapRecover!() + } + } + } + + Spacer() + Button { + /// Reuse this struct for Create or Recover + switch navigateStart { + case .create: + viewModel.didTapCreate!() + case .recover: + viewModel.didTapRecover!() + } + + } label: { + ZStack { + RoundedRectangle(cornerRadius: bigButtonCornerRadius) + .frame(width: width * 0.9, height: 45, alignment: .center) + .foregroundColor(.litewalletDarkBlue) + + Text(S.Notifications.signupCancel.localize()) + .frame(width: width * 0.9, height: 45, alignment: .center) + .font(buttonLightFont) + .foregroundColor(.white) + .overlay( + RoundedRectangle(cornerRadius: bigButtonCornerRadius) + .stroke(.white, lineWidth: 0.5) + ) + } + } + .padding(.bottom, genericPad) + } + } + } + .edgesIgnoringSafeArea(.top) + } +} + +/// Crashes when env Obj is added +// #Preview { +// AnnounceUpdatesView(navigateStart: .create, +// didTapContinue: .constant(false)) +// } diff --git a/litewallet/src/AppDelegate.swift b/litewallet/src/AppDelegate.swift new file mode 100644 index 000000000..8ae9e44bb --- /dev/null +++ b/litewallet/src/AppDelegate.swift @@ -0,0 +1,124 @@ +import Firebase +import LocalAuthentication +import PushNotifications +import SwiftUI +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + let applicationController = ApplicationController() + let pushNotifications = PushNotifications.shared + func application(_ application: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool + { + setFirebaseConfiguration() + + updateCurrentUserLocale(localeId: Locale.current.identifier) + + guard let thisWindow = window else { return false } + + thisWindow.tintColor = .liteWalletBlue + + UIView.swizzleSetFrame() + + applicationController.launch(application: application, window: thisWindow) + + LWAnalytics.logEventWithParameters(itemName: ._20191105_AL) + + Bundle.setLanguage(UserDefaults.selectedLanguage) + + // Pusher + pushNotifications.start(instanceId: Partner.partnerKeyPath(name: .pusherStaging)) + // pushNotifications.registerForRemoteNotifications() + let generaliOSInterest = "general-ios" + let debugGeneraliOSInterest = "debug-general-ios" + + try? pushNotifications + .addDeviceInterest(interest: generaliOSInterest) + try? pushNotifications + .addDeviceInterest(interest: debugGeneraliOSInterest) + + let interests = pushNotifications.getDeviceInterests()?.joined(separator: "|") ?? "" + let device = UIDevice.current.identifierForVendor?.uuidString ?? "ID" + let interestesDict: [String: String] = ["device_id": device, + "pusher_interests": interests] + + LWAnalytics.logEventWithParameters(itemName: ._20231202_RIGI, properties: interestesDict) + + return true + } + + func applicationDidBecomeActive(_: UIApplication) { + UIApplication.shared.applicationIconBadgeNumber = 0 + } + + func applicationWillEnterForeground(_: UIApplication) { + applicationController.willEnterForeground() + } + + func applicationDidEnterBackground(_: UIApplication) { + applicationController.didEnterBackground() + } + + func applicationWillResignActive(_: UIApplication) { + applicationController.willResignActive() + } + + func application(_: UIApplication, shouldAllowExtensionPointIdentifier _: UIApplication.ExtensionPointIdentifier) -> Bool + { + return false // disable extensions such as custom keyboards for security purposes + } + + func application(_: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool + { + return applicationController.open(url: url) + } + + func application(_: UIApplication, shouldSaveApplicationState _: NSCoder) -> Bool { + return true + } + + func application(_: UIApplication, shouldRestoreApplicationState _: NSCoder) -> Bool { + return true + } + + /// Pusher Related funcs + func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + pushNotifications.registerDeviceToken(deviceToken) + + let acceptanceDict: [String: String] = ["did_accept": "true", + "date_accepted": Date().ISO8601Format()] + LWAnalytics.logEventWithParameters(itemName: ._20231225_UAP, properties: acceptanceDict) + } + + func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler _: @escaping (UIBackgroundFetchResult) -> Void) + { + pushNotifications.handleNotification(userInfo: userInfo) + } + + /// Sets the correct Google Services plist file + private func setFirebaseConfiguration() { + // Load a Firebase debug config file. + // let filePath = Bundle.main.path(forResource: "Debug-GoogleService-Info", ofType: "plist") + + let filePath = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") + + if let fboptions = FirebaseOptions(contentsOfFile: filePath!) { + FirebaseApp.configure(options: fboptions) + } else { + assertionFailure("Couldn't load Firebase config file") + } + } + + /// Check Locale + func updateCurrentUserLocale(localeId: String) { + let suffix = String(localeId.suffix(3)) + + if suffix == "_US" { + UserDefaults.userIsInUSA = true + } else { + UserDefaults.userIsInUSA = false + } + } +} diff --git a/litewallet/src/ApplicationController.swift b/litewallet/src/ApplicationController.swift new file mode 100644 index 000000000..2aa0e518e --- /dev/null +++ b/litewallet/src/ApplicationController.swift @@ -0,0 +1,320 @@ +import StoreKit +import UIKit + +let timeSinceLastExitKey = "TimeSinceLastExit" +let shouldRequireLoginTimeoutKey = "ShouldRequireLoginTimeoutKey" +let numberOfLitewalletLaunches = "NumberOfLitewalletLaunches" +let hasSeenAnnounceView = "HasSeedAnnounceView" +let LITEWALLET_APP_STORE_ID = 1_119_332_592 + +class ApplicationController: Subscriber, Trackable { + // Ideally the window would be private, but is unfortunately required + // by the UIApplicationDelegate Protocol + + var window: UIWindow? + fileprivate let store = Store() + private var startFlowController: StartFlowPresenter? + private var modalPresenter: ModalPresenter? + fileprivate var walletManager: WalletManager? + private var walletCoordinator: WalletCoordinator? + private var exchangeUpdater: ExchangeUpdater? + private var feeUpdater: FeeUpdater? + private let transitionDelegate: ModalTransitionDelegate + private var kvStoreCoordinator: KVStoreCoordinator? + private var mainViewController: MainViewController? + fileprivate var application: UIApplication? + private var urlController: URLController? + private var defaultsUpdater: UserDefaultsUpdater? + private var reachability = ReachabilityMonitor() + private let noAuthApiClient = BRAPIClient(authenticator: NoAuthAuthenticator()) + private var fetchCompletionHandler: ((UIBackgroundFetchResult) -> Void)? + private var launchURL: URL? + private var hasPerformedWalletDependentInitialization = false + private var didInitWallet = false + + init() { + transitionDelegate = ModalTransitionDelegate(type: .transactionDetail, store: store) + DispatchQueue.walletQueue.async { + guardProtected(queue: DispatchQueue.walletQueue) { + self.initWallet() + } + } + } + + private func initWallet() { + walletManager = try? WalletManager(store: store, dbPath: nil) + _ = walletManager?.wallet // attempt to initialize wallet + DispatchQueue.main.async { + self.didInitWallet = true + if !self.hasPerformedWalletDependentInitialization { + self.didInitWalletManager() + } + } + } + + func launch(application: UIApplication, window: UIWindow?) { + self.application = application + self.window = window + application.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum) + setup() + reachability.didChange = { isReachable in + if !isReachable { + self.reachability.didChange = { isReachable in + if isReachable { + self.retryAfterIsReachable() + } + } + } + } + + if !hasPerformedWalletDependentInitialization, didInitWallet { + didInitWalletManager() + } + } + + private func setup() { + setupDefaults() + countLaunches() + setupRootViewController() + window?.makeKeyAndVisible() + offMainInitialization() + store.subscribe(self, name: .reinitWalletManager(nil), callback: { + guard let trigger = $0 else { return } + if case let .reinitWalletManager(callback) = trigger { + if let callback = callback { + self.store.removeAllSubscriptions() + self.store.perform(action: Reset()) + self.setup() + DispatchQueue.walletQueue.async { + do { + self.walletManager = try WalletManager(store: self.store, dbPath: nil) + _ = self.walletManager?.wallet // attempt to initialize wallet + } catch { + assertionFailure("Error creating new wallet: \(error)") + } + DispatchQueue.main.async { + self.didInitWalletManager() + callback() + } + } + } + } + }) + + TransactionManager.sharedInstance.fetchTransactionData(store: store) + } + + func willEnterForeground() { + guard let walletManager = walletManager else { return } + guard !walletManager.noWallet else { return } + if shouldRequireLogin() { + store.perform(action: RequireLogin()) + } + DispatchQueue.walletQueue.async { + walletManager.peerManager?.connect() + } + exchangeUpdater?.refresh(completion: {}) + feeUpdater?.refresh() + walletManager.apiClient?.kv?.syncAllKeys { print("KV finished syncing. err: \(String(describing: $0))") } + walletManager.apiClient?.updateFeatureFlags() + if modalPresenter?.walletManager == nil { + modalPresenter?.walletManager = walletManager + } + } + + func retryAfterIsReachable() { + guard let walletManager = walletManager else { return } + guard !walletManager.noWallet else { return } + DispatchQueue.walletQueue.async { + walletManager.peerManager?.connect() + } + exchangeUpdater?.refresh(completion: {}) + feeUpdater?.refresh() + walletManager.apiClient?.kv?.syncAllKeys { print("KV finished syncing. err: \(String(describing: $0))") } + walletManager.apiClient?.updateFeatureFlags() + if modalPresenter?.walletManager == nil { + modalPresenter?.walletManager = walletManager + } + } + + func didEnterBackground() { + if store.state.walletState.syncState == .success { + DispatchQueue.walletQueue.async { + self.walletManager?.peerManager?.disconnect() + } + } + // Save the backgrounding time if the user is logged in + if !store.state.isLoginRequired { + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: timeSinceLastExitKey) + } + walletManager?.apiClient?.kv?.syncAllKeys { print("KV finished syncing. err: \(String(describing: $0))") } + } + + func performFetch(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + fetchCompletionHandler = completionHandler + } + + func open(url: URL) -> Bool { + if let urlController = urlController { + return urlController.handleUrl(url) + } else { + launchURL = url + return false + } + } + + private func didInitWalletManager() { + guard let walletManager = walletManager else { assertionFailure("WalletManager should exist!"); return } + guard let rootViewController = window?.rootViewController else { return } + guard let window = window else { return } + + hasPerformedWalletDependentInitialization = true + store.perform(action: PinLength.set(walletManager.pinLength)) + walletCoordinator = WalletCoordinator(walletManager: walletManager, store: store) + modalPresenter = ModalPresenter(store: store, walletManager: walletManager, window: window, apiClient: noAuthApiClient) + exchangeUpdater = ExchangeUpdater(store: store, walletManager: walletManager) + feeUpdater = FeeUpdater(walletManager: walletManager, store: store) + startFlowController = StartFlowPresenter(store: store, walletManager: walletManager, rootViewController: rootViewController) + mainViewController?.walletManager = walletManager + defaultsUpdater = UserDefaultsUpdater(walletManager: walletManager) + urlController = URLController(store: store, walletManager: walletManager) + if let url = launchURL { + _ = urlController?.handleUrl(url) + launchURL = nil + } + + if UIApplication.shared.applicationState != .background { + if walletManager.noWallet { + addWalletCreationListener() + store.perform(action: ShowStartFlow()) + } else { + modalPresenter?.walletManager = walletManager + DispatchQueue.walletQueue.async { + walletManager.peerManager?.connect() + } + startDataFetchers() + } + + // For when watch app launches app in background + } else { + DispatchQueue.walletQueue.async { [weak self] in + walletManager.peerManager?.connect() + if self?.fetchCompletionHandler != nil { + self?.performBackgroundFetch() + } + } + + exchangeUpdater?.refresh(completion: { + NSLog("Rates were updated") + }) + } + } + + private func shouldRequireLogin() -> Bool { + let then = UserDefaults.standard.double(forKey: timeSinceLastExitKey) + let timeout = UserDefaults.standard.double(forKey: shouldRequireLoginTimeoutKey) + let now = Date().timeIntervalSince1970 + return now - then > timeout + } + + private func setupRootViewController() { + mainViewController = MainViewController(store: store) + window?.rootViewController = mainViewController + } + + private func startDataFetchers() { + walletManager?.apiClient?.updateFeatureFlags() + initKVStoreCoordinator() + feeUpdater?.refresh() + defaultsUpdater?.refresh() + walletManager?.apiClient?.events?.up() + + exchangeUpdater?.refresh(completion: { + NSLog("::: Refreshed fiat rates") + }) + } + + private func addWalletCreationListener() { + store.subscribe(self, name: .didCreateOrRecoverWallet, callback: { _ in + self.modalPresenter?.walletManager = self.walletManager + self.startDataFetchers() + self.mainViewController?.didUnlockLogin() + }) + } + + private func initKVStoreCoordinator() { + guard let kvStore = walletManager?.apiClient?.kv + else { + NSLog("kvStore not initialized") + return + } + + guard kvStoreCoordinator == nil + else { + NSLog("kvStoreCoordinator not initialized") + return + } + + kvStore.syncAllKeys { error in + print("KV finished syncing. err: \(String(describing: error))") + self.walletCoordinator?.kvStore = kvStore + self.kvStoreCoordinator = KVStoreCoordinator(store: self.store, kvStore: kvStore) + self.kvStoreCoordinator?.retreiveStoredWalletInfo() + self.kvStoreCoordinator?.listenForWalletChanges() + } + } + + private func offMainInitialization() { + DispatchQueue.global(qos: .background).async { + _ = Rate.symbolMap // Initialize currency symbol map + } + } + + func performBackgroundFetch() { + saveEvent("appController.performBackgroundFetch") + let group = DispatchGroup() + if let peerManager = walletManager?.peerManager, peerManager.syncProgress(fromStartHeight: peerManager.lastBlockHeight) < 1.0 + { + group.enter() + LWAnalytics.logEventWithParameters(itemName: ._20200111_DEDG) + + store.lazySubscribe(self, selector: { $0.walletState.syncState != $1.walletState.syncState }, callback: { state in + if self.fetchCompletionHandler != nil { + if state.walletState.syncState == .success { + DispatchQueue.walletConcurrentQueue.async { + peerManager.disconnect() + self.saveEvent("appController.peerDisconnect") + DispatchQueue.main.async { + LWAnalytics.logEventWithParameters(itemName: ._20200111_DLDG) + group.leave() + } + } + } + } + }) + } + + group.enter() + LWAnalytics.logEventWithParameters(itemName: ._20200111_DEDG) + Async.parallel(callbacks: [ + { self.exchangeUpdater?.refresh(completion: $0) }, + { self.feeUpdater?.refresh(completion: $0) }, + { self.walletManager?.apiClient?.events?.sync(completion: $0) }, + { self.walletManager?.apiClient?.updateFeatureFlags(); $0() }, + ], completion: { + LWAnalytics.logEventWithParameters(itemName: ._20200111_DLDG) + group.leave() + }) + + DispatchQueue.global(qos: .utility).async { + if group.wait(timeout: .now() + 25.0) == .timedOut { + self.saveEvent("appController.backgroundFetchFailed") + self.fetchCompletionHandler?(.failed) + } else { + self.saveEvent("appController.backgroundFetchNewData") + self.fetchCompletionHandler?(.newData) + } + self.fetchCompletionHandler = nil + } + } +} diff --git a/litewallet/src/BRCore.swift b/litewallet/src/BRCore.swift new file mode 100644 index 000000000..b3e105978 --- /dev/null +++ b/litewallet/src/BRCore.swift @@ -0,0 +1,163 @@ +import BRCore +import Foundation + +typealias BRTxRef = UnsafeMutablePointer +typealias BRBlockRef = UnsafeMutablePointer + +// Used for convert byte array to LTC Address +let characterLengthConstant = 75 + +/// BRPeerManagerError: Error +enum BRPeerManagerError: Error { + case posixError(errorCode: Int32, description: String) +} + +// MARK: - BRCore Protocols + +protocol BRWalletListener { + func balanceChanged(_ balance: UInt64) + func txAdded(_ tx: BRTxRef) + func txUpdated(_ txHashes: [UInt256], blockHeight: UInt32, timestamp: UInt32) + func txDeleted(_ txHash: UInt256, notifyUser: Bool, recommendRescan: Bool) +} + +protocol BRPeerManagerListener { + func syncStarted() + func syncStopped(_ error: BRPeerManagerError?) + func txStatusUpdate() + func saveBlocks(_ replace: Bool, _ blocks: [BRBlockRef?]) + func savePeers(_ replace: Bool, _ peers: [BRPeer]) + func networkIsReachable() -> Bool +} + +private func secureAllocate(allocSize: CFIndex, hint _: CFOptionFlags, info _: UnsafeMutableRawPointer?) + -> UnsafeMutableRawPointer? +{ + guard let ptr = malloc(MemoryLayout.stride + allocSize) else { return nil } + // keep track of the size of the allocation so it can be cleansed before deallocation + ptr.storeBytes(of: allocSize, as: CFIndex.self) + return ptr.advanced(by: MemoryLayout.stride) +} + +private func secureDeallocate(ptr: UnsafeMutableRawPointer?, info _: UnsafeMutableRawPointer?) { + guard let ptr = ptr else { return } + let allocSize = ptr.load(fromByteOffset: -MemoryLayout.stride, as: CFIndex.self) + memset(ptr, 0, allocSize) // cleanse allocated memory + free(ptr.advanced(by: -MemoryLayout.stride)) +} + +private func secureReallocate(ptr: UnsafeMutableRawPointer?, newsize: CFIndex, hint: CFOptionFlags, + info: UnsafeMutableRawPointer?) -> UnsafeMutableRawPointer? +{ + // there's no way to tell ahead of time if the original memory will be deallocted even if the new size is smaller + // than the old size, so just cleanse and deallocate every time + guard let ptr = ptr else { return nil } + let newptr = secureAllocate(allocSize: newsize, hint: hint, info: info) + let allocSize = ptr.load(fromByteOffset: -MemoryLayout.stride, as: CFIndex.self) + if newptr != nil { memcpy(newptr, ptr, (allocSize < newsize) ? allocSize : newsize) } + secureDeallocate(ptr: ptr, info: info) + return newptr +} + +/// Converts CChar to Int8 and String +/// - Parameter characterArray: [CChar] +/// - Returns: String +public func charInt8ToString(charArray: (CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar)) -> String { + let addressCharArray = [unichar(charArray.0), unichar(charArray.1), unichar(charArray.2), unichar(charArray.3), unichar(charArray.4), + unichar(charArray.5), unichar(charArray.6), unichar(charArray.7), unichar(charArray.8), unichar(charArray.9), + unichar(charArray.10), unichar(charArray.11), unichar(charArray.12), unichar(charArray.13), unichar(charArray.14), + unichar(charArray.15), unichar(charArray.16), unichar(charArray.17), unichar(charArray.18), unichar(charArray.19), + unichar(charArray.20), unichar(charArray.21), unichar(charArray.22), unichar(charArray.23), unichar(charArray.24), + unichar(charArray.25), unichar(charArray.26), unichar(charArray.27), unichar(charArray.28), unichar(charArray.29), + unichar(charArray.30), unichar(charArray.31), unichar(charArray.32), unichar(charArray.33), unichar(charArray.34), + unichar(charArray.35), unichar(charArray.36), unichar(charArray.37), unichar(charArray.38), unichar(charArray.39), + unichar(charArray.40), unichar(charArray.41), unichar(charArray.42), unichar(charArray.43), unichar(charArray.44), + unichar(charArray.45), unichar(charArray.46), unichar(charArray.47), unichar(charArray.48), unichar(charArray.49), + unichar(charArray.50), unichar(charArray.51), unichar(charArray.52), unichar(charArray.53), unichar(charArray.54), + unichar(charArray.55), unichar(charArray.56), unichar(charArray.57), unichar(charArray.58), unichar(charArray.59), + unichar(charArray.60), unichar(charArray.61), unichar(charArray.62), unichar(charArray.63), unichar(charArray.64), + unichar(charArray.65), unichar(charArray.66), unichar(charArray.67), unichar(charArray.68), unichar(charArray.69), + unichar(charArray.70), unichar(charArray.71), unichar(charArray.72), unichar(charArray.73), unichar(charArray.74)] + + let length = addressCharArray.reduce(0) { $1 != 0 ? $0 + 1 : $0 } + return String(NSString(characters: addressCharArray, length: length)) +} + +/// Converts String to CChar +/// - Parameter String +/// - Returns: characterArray: [CChar] +public func stringToCharArray(addressString: String) -> [CChar] { + let arrayLength = 75 + + var charArray = [CChar]() + + let stringArray = Array(addressString) + + for index in 0 ... arrayLength - 1 { + if index < stringArray.count { + let value = Int8(stringArray[index] + .unicodeScalars + .map { $0.value }.reduce(0, +)) + charArray.append(CChar(value)) + } else { + charArray.append(0) + } + } + + return charArray +} + +// since iOS does not page memory to disk, all we need to do is cleanse allocated memory prior to deallocation +public let secureAllocator: CFAllocator = { + var context = CFAllocatorContext() + context.version = 0 + CFAllocatorGetContext(kCFAllocatorDefault, &context) + context.allocate = secureAllocate + context.reallocate = secureReallocate + context.deallocate = secureDeallocate + return CFAllocatorCreate(kCFAllocatorDefault, &context).takeRetainedValue() +}() + +// 8 element tuple equatable +public func == (l: (A, B, C, D, E, F, G, H), r: (A, B, C, D, E, F, G, H)) -> Bool +{ + return l.0 == r.0 && l.1 == r.1 && l.2 == r.2 && l.3 == r.3 && l.4 == r.4 && l.5 == r.5 && l.6 == r.6 && l.7 == r.7 +} + +public func != (l: (A, B, C, D, E, F, G, H), r: (A, B, C, D, E, F, G, H)) -> Bool +{ + return l.0 != r.0 || l.1 != r.1 || l.2 != r.2 || l.3 != r.3 || l.4 != r.4 || l.5 != r.5 || l.6 != r.6 || l.7 != r.7 +} + +// 33 element tuple equatable +public func == +(l: (A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z, a, b, c, d, e, f, g), + r: (A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z, a, b, c, d, e, f, g)) -> Bool +{ + return l.0 == r.0 && l.1 == r.1 && l.2 == r.2 && l.3 == r.3 && l.4 == r.4 && l.5 == r.5 && l.6 == r.6 && + l.7 == r.7 && l.8 == r.8 && l.9 == r.9 && l.10 == r.10 && l.11 == r.11 && l.12 == r.12 && l.13 == r.13 && + l.14 == r.14 && l.15 == r.15 && l.16 == r.16 && l.17 == r.17 && l.18 == r.18 && l.19 == r.19 && l.20 == r.20 && + l.21 == r.21 && l.22 == r.22 && l.23 == r.23 && l.24 == r.24 && l.25 == r.25 && l.26 == r.26 && l.27 == r.27 && + l.28 == r.28 && l.29 == r.29 && l.30 == r.30 && l.31 == r.31 && l.32 == r.32 +} + +public func != +(l: (A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z, a, b, c, d, e, f, g), + r: (A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z, a, b, c, d, e, f, g)) -> Bool +{ + return l.0 != r.0 || l.1 != r.1 || l.2 != r.2 || l.3 != r.3 || l.4 != r.4 || l.5 != r.5 || l.6 != r.6 || + l.7 != r.7 || l.8 != r.8 || l.9 != r.9 || l.10 != r.10 || l.11 != r.11 || l.12 != r.12 || l.13 != r.13 || + l.14 != r.14 || l.15 != r.15 || l.16 != r.16 || l.17 != r.17 || l.18 != r.18 || l.19 != r.19 || l.20 != r.20 || + l.21 != r.21 || l.22 != r.22 || l.23 != r.23 || l.24 != r.24 || l.25 != r.25 || l.26 != r.26 || l.27 != r.27 || + l.28 != r.28 || l.29 != r.29 || l.30 != r.30 || l.31 != r.31 || l.32 != r.32 +} diff --git a/litewallet/src/Constants/ArticleIds.swift b/litewallet/src/Constants/ArticleIds.swift new file mode 100644 index 000000000..94cfbeec1 --- /dev/null +++ b/litewallet/src/Constants/ArticleIds.swift @@ -0,0 +1,5 @@ +import Foundation + +enum ArticleIds { + static let nothing = "nothing" +} diff --git a/litewallet/src/Constants/Constants.swift b/litewallet/src/Constants/Constants.swift new file mode 100644 index 000000000..def14cb0b --- /dev/null +++ b/litewallet/src/Constants/Constants.swift @@ -0,0 +1,127 @@ +import UIKit + +let π: CGFloat = .pi +let customUserAgent: String = "litewallet-ios" +let swiftUICellPadding = 12.0 +let bigButtonCornerRadius = 15.0 + +struct FoundationSupport { + static let dashboard = "https://support.litewallet.io/" +} + +struct APIServer { + static let baseUrl = "https://api-prod.lite-wallet.org/" +} + +struct Padding { + subscript(multiplier: Int) -> CGFloat { + return CGFloat(multiplier) * 8.0 + } + + subscript(multiplier: Double) -> CGFloat { + return CGFloat(multiplier) * 8.0 + } +} + +struct C { + static let padding = Padding() + struct Sizes { + static let buttonHeight: CGFloat = 48.0 + static let sendButtonHeight: CGFloat = 165.0 + static let headerHeight: CGFloat = 48.0 + static let largeHeaderHeight: CGFloat = 220.0 + } + + static var defaultTintColor: UIColor = UIView().tintColor + + static let animationDuration: TimeInterval = 0.3 + static let secondsInDay: TimeInterval = 86400 + static let maxMoney: UInt64 = 84_000_000 * 100_000_000 + static let satoshis: UInt64 = 100_000_000 + static let walletQueue = "com.litecoin.walletqueue" + static let btcCurrencyCode = "LTC" + static let null = "(null)" + static let maxMemoLength = 250 + static let feedbackEmail = "feedback@litecoinfoundation.zendesk.com" + static let supportEmail = "support@litecoinfoundation.zendesk.com" + + static let reviewLink = "https://itunes.apple.com/app/loafwallet-litecoin-wallet/id1119332592?action=write-review" + static let signupURL = "https://litewallet.io/mobile-signup/signup.html" + static let stagingSignupURL = "https://staging-litewallet-io.webflow.io/mobile-signup/signup" + + static var standardPort: Int { + return E.isTestnet ? 19335 : 9333 + } + + static let troubleshootingQuestions = """ + + + + + + + + + + + +
+
+ +
Please reply to this email with the following information so that we can prepare to help you solve your Litewallet issue.
+
+
1. What version of software running on your mobile device (e.g.; iOS 13.7 or iOS 14)?
+
+
+
2. What version of Litewallet software is on your mobile device (found on the login view)?
+
+
+
3. What type of iPhone do you have?
+
+
+
4. How we can help?
+
+
+
+
+ + + """ +} + +struct AppVersion { + static let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String + static let versionNumber = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + static let string = "v" + versionNumber! + " (\(buildNumber!))" +} + +/// False Positive Rates +/// The rate at which the requested numner of false +/// addresses are sent to the syncing node. The more +/// fp sent the less likely that the node cannot +/// identify the Litewallet user. Used when deploying the +/// Bloom Filter. The 3 options are from testing ideal +/// rates. +enum FalsePositiveRates: Double { + case lowPrivacy = 0.00005 + case semiPrivate = 0.00008 + case anonymous = 0.0005 +} diff --git a/litewallet/src/Constants/Functions.swift b/litewallet/src/Constants/Functions.swift new file mode 100644 index 000000000..7805bf290 --- /dev/null +++ b/litewallet/src/Constants/Functions.swift @@ -0,0 +1,62 @@ +import UIKit + +func guardProtected(queue: DispatchQueue, callback: @escaping () -> Void) { + DispatchQueue.main.async { + if UIApplication.shared.isProtectedDataAvailable { + callback() + } else { + var observer: Any? + observer = NotificationCenter + .default + .addObserver(forName: UIApplication.protectedDataDidBecomeAvailableNotification, + object: nil, + queue: nil, + using: { _ in + queue.async { + callback() + } + if let observer = observer { + NotificationCenter.default.removeObserver(observer) + } + }) + } + } +} + +func strongify(_ context: Context, closure: @escaping (Context) -> Void) -> () -> Void +{ + return { [weak context] in + guard let strongContext = context else { return } + closure(strongContext) + } +} + +func strongify(_ context: Context?, closure: @escaping (Context, Arguments) -> Void) -> (Arguments) -> Void +{ + return { [weak context] arguments in + guard let strongContext = context else { return } + closure(strongContext, arguments) + } +} + +/// Description: 1701029422 +func tieredOpsFee(amount: UInt64) -> UInt64 { + switch amount { + case 0 ..< 1_398_000: + return 69900 + case 1_398_000 ..< 6_991_000: + return 111_910 + case 6_991_000 ..< 27_965_000: + return 279_700 + case 27_965_000 ..< 139_820_000: + return 699_540 + case 139_820_000 ..< 279_653_600: + return 1_049_300 + case 279_653_600 ..< 699_220_000: + return 1_398_800 + case 699_220_000 ..< 1_398_440_000: + return 2_797_600 + default: + return 2_797_600 + } +} diff --git a/litewallet/src/Constants/Strings.swift b/litewallet/src/Constants/Strings.swift new file mode 100644 index 000000000..36ce677a1 --- /dev/null +++ b/litewallet/src/Constants/Strings.swift @@ -0,0 +1,724 @@ +import Foundation + +enum S { + enum CreateStep { + enum MainTitle { + static let intro = Localization(key: "MainTitle.intro", value: "Quick Start", comment: "Litewallet Quick Start") + static let checkboxes = Localization(key: "MainTitle.checkboxes", value: "Stay Connected", comment: "Stay Connected") + static let seedPhrase = Localization(key: "MainTitle.seedPhrase", value: "Protect your Litecoin!", comment: "Protect your Litecoin!") + static let finished = Localization(key: "MainTitle.finished", value: "Confirm and Go", comment: "Confirm and Go") + } + + enum DetailedMessage { + static let intro = Localization(key: "DetailedMessage.intro", value: "Take the next 5 minutes to secure your Litecoin.", comment: "Detailed message for intro") + static let checkboxes = Localization(key: "DetailedMessage.checkboxes", value: "Sign up for the push notifications to learn Litecoin news. Unsubscribe any time in Settings", comment: "Settings button label") + static let seedPhrase = Localization(key: "DetailedMessage.seedPhrase", value: "Please write this down", comment: "Warning seed phrase") + static let finished = Localization(key: "DetailedMessage.finished", value: "Settings", comment: "Settings button label") + } + + enum ExtendedMessage { + static let intro = Localization(key: "ExtendedMessage.intro", value: "Please find a private place to write down your PIN and seed phrase.", comment: "extended message") + static let checkboxes = Localization(key: "ExtendedMessage.checkboxes", value: "Enter your email to get updates about Litewallet", comment: "Email sign up website") + static let seedPhrase = Localization(key: "ExtendedMessage.seedPhrase", value: "Settings", comment: "Settings button label") + static let finished = Localization(key: "ExtendedMessage.finished", value: "Settings", comment: "Settings button label") + } + + enum Bullet1 { + static let intro = Localization(key: "Bullet1.intro", value: "Litewallet is from the Litecoin Foundation and Charlie Lee, creator of Litecoin.", comment: "") + static let checkboxes = Localization(key: "Bullet1.checkboxes", value: "Settings", comment: "Settings button label") + static let seedPhrase = Localization(key: "Bullet1.seedPhrase", value: "Settings", comment: "Settings button label") + static let finished = Localization(key: "Bullet1.finished", value: "Settings", comment: "Settings button label") + } + + enum Bullet2 { + static let intro = Localization(key: "Bullet2.intro", value: "Settings", comment: "Settings button label") + static let checkboxes = Localization(key: "Bullet2.checkboxes", value: "Settings", comment: "Settings button label") + static let seedPhrase = Localization(key: "Bullet2.seedPhrase", value: "Settings", comment: "Settings button label") + static let finished = Localization(key: "Bullet2.finished", value: "Settings", comment: "Settings button label") + } + + enum Bullet3 { + static let intro = Localization(key: "Bullet3.intro", value: "Settings", comment: "Settings button label") + static let checkboxes = Localization(key: "Bullet3.checkboxes", value: "Settings", comment: "Settings button label") + static let seedPhrase = Localization(key: "Bullet3.seedPhrase", value: "Settings", comment: "Settings button label") + static let finished = Localization(key: "Bullet3.finished", value: "Settings", comment: "Settings button label") + } + } + + enum Symbols { + static let photons = "mł" + static let lites = "ł" + static let ltc = "Ł" + static let narrowSpace = "\u{2009}" + static let lock = "\u{1F512}" + static let redX = "\u{274C}" + static func currencyButtonTitle(maxDigits: Int) -> String { + switch maxDigits { + case 2: + return "photons\(S.Symbols.narrowSpace)(m\(S.Symbols.lites))" + case 5: + return "lites\(S.Symbols.narrowSpace)(\(S.Symbols.lites))" + case 8: + return "LTC\(S.Symbols.narrowSpace)(\(S.Symbols.ltc))" + default: + return "lites\(S.Symbols.narrowSpace)(\(S.Symbols.lites))" + } + } + } + + enum Conjuction { + static let asOf = Localization(key: "Conjunction.asOf", value: "as of", comment: "as of a time or date") + } + + // MARK: - Generic Button labels + + enum Button { + static let ok = Localization(key: "Button.ok", value: "OK", comment: "OK button label") + static let cancel = Localization(key: "Button.cancel", value: "Cancel", comment: "Cancel button label") + static let settings = Localization(key: "Button.settings", value: "Settings", comment: "Settings button label") + static let submit = Localization(key: "Button.submit", value: "Submit", comment: "Settings button label") + static let ignore = Localization(key: "Button.ignore", value: "Ignore", comment: "Ignore button label") + static let yes = Localization(key: "Button.yes", value: "Yes", comment: "Yes button") + static let no = Localization(key: "Button.no", value: "No", comment: "No button") + static let send = Localization(key: "Button.send", value: "send", comment: "send button") + static let receive = Localization(key: "Button.receive", value: "receive", comment: "receive button") + static let menu = Localization(key: "Button.menu", value: "menu", comment: "menu button") + static let buy = Localization(key: "Button.buy", value: "buy", comment: "buy button") + static let resetFields = Localization(key: "Button.resetFields", value: "reset", comment: "resetFields") + } + + enum LitewalletAlert { + static let warning = Localization(key: "Alert.warning", value: "Warning", comment: "Warning alert title") + static let error = Localization(key: "Alert.error", value: "Error", comment: "Error alert title") + static let noInternet = Localization(key: "Alert.noInternet", value: "No internet connection found. Check your connection and try again.", comment: "No internet alert message") + static let corruptionError = Localization(key: "Alert.corruptionError", value: "Database Corruption Error", comment: "Corruption Error alert title") + static let corruptionMessage = Localization(key: "Alert.corruptionMessage", value: "Your local database is corrupted. Go to Settings > Blockchain: Settings > Delete Database to refresh", comment: "Corruption Error alert title") + } + + enum Scanner { + static let flashButtonLabel = Localization(key: "Scanner.flashButtonLabel", value: "Camera Flash", comment: "Scan Litecoin address camera flash toggle") + } + + enum Send { + static let title = Localization(key: "Send.title", value: "Send", comment: "Send modal title") + static let toLabel = Localization(key: "Send.toLabel", value: "To", comment: "Send money to label") + static let enterLTCAddressLabel = Localization(key: "Send.enterLTCAddress", value: "Enter LTC Address", comment: "Enter LTC Address") + static let amountLabel = Localization(key: "Send.amountLabel", value: "Amount", comment: "Send money amount label") + static let descriptionLabel = Localization(key: "Send.descriptionLabel", value: "Memo", comment: "Description for sending money label") + static let sendLabel = Localization(key: "Send.sendLabel", value: "Send", comment: "Send button label") + static let pasteLabel = Localization(key: "Send.pasteLabel", value: "Paste", comment: "Paste button label") + static let scanLabel = Localization(key: "Send.scanLabel", value: "Scan", comment: "Scan button label") + static let invalidAddressTitle = Localization(key: "Send.invalidAddressTitle", value: "Invalid Address", comment: "Invalid address alert title") + static let invalidAddressMessage = Localization(key: "Send.invalidAddressMessage", value: "The destination address is not a valid Litecoin address.", comment: "Invalid address alert message") + static let invalidAddressOnPasteboard = Localization(key: "Send.invalidAddressOnPasteboard", value: "Pasteboard does not contain a valid Litecoin address.", comment: "Invalid address on pasteboard message") + static let emptyPasteboard = Localization(key: "Send.emptyPasteboard", value: "Pasteboard is empty", comment: "Empty pasteboard error message") + static let cameraUnavailableTitle = Localization(key: "Send.cameraUnavailableTitle", value: "Litewallet is not allowed to access the camera", comment: "Camera not allowed alert title") + static let cameraUnavailableMessage = Localization(key: "Send.cameraunavailableMessage", value: "Go to Settings to allow camera access.", comment: "Camera not allowed message") + static let balance = Localization(key: "Send.balance", value: "Balance: %1$@", comment: "Balance: $4.00") + static let fee = Localization(key: "Send.fees", value: "Fees: %1$@", comment: "Fees: $0.10") + static let feeBlank = Localization(key: "Send.feeBlank", value: "Fees:", comment: "Fees: ") + static let bareFee = Localization(key: "Send.fee", value: "Fee: %1$@", comment: "Fee: $0.01") + static let containsAddress = Localization(key: "Send.containsAddress", value: "The destination is your own address. You cannot send to yourself.", comment: "Warning when sending to self.") + enum UsedAddress { + static let title = Localization(key: "Send.UsedAddress.title", value: "Address Already Used", comment: "Adress already used alert title") + static let firstLine = Localization(key: "Send.UsedAddress.firstLine", value: "Litecoin addresses are intended for single use only.", comment: "Adress already used alert message - first part") + static let secondLine = Localization(key: "Send.UsedAddress.secondLIne", value: "Re-use reduces privacy for both you and the recipient and can result in loss if the recipient doesn't directly control the address.", comment: "Adress already used alert message - second part") + } + + static let identityNotCertified = Localization(key: "Send.identityNotCertified", value: "Payee identity isn't certified.", comment: "Payee identity not certified alert title.") + static let createTransactionError = Localization(key: "Send.creatTransactionError", value: "Could not create transaction.", comment: "Could not create transaction alert title") + static let publicTransactionError = Localization(key: "Send.publishTransactionError", value: "Could not publish transaction.", comment: "Could not publish transaction alert title") + static let noAddress = Localization(key: "Send.noAddress", value: "Please enter the recipient's address.", comment: "Empty address alert message") + static let noAmount = Localization(key: "Send.noAmount", value: "Please enter an amount to send.", comment: "Emtpy amount alert message") + static let isRescanning = Localization(key: "Send.isRescanning", value: "Sending is disabled during a full rescan.", comment: "Is rescanning error message") + static let remoteRequestError = Localization(key: "Send.remoteRequestError", value: "Could not load payment request", comment: "Could not load remote request error message") + static let loadingRequest = Localization(key: "Send.loadingRequest", value: "Loading Request", comment: "Loading request activity view message") + static let insufficientFunds = Localization(key: "Send.insufficientFunds", value: "Insufficient Funds", comment: "Insufficient funds error") + static let barItemTitle = Localization(key: "Send.barItemTitle", value: "Send", comment: "Send Bar Item Title") + + enum UnstoppableDomains { + static let placeholder = Localization(key: "Send.UnstoppableDomains.placeholder", value: "Enter a .crypto or .zil domain", comment: "Enter a .crypto,.zil domain") + static let simplePlaceholder = Localization(key: "Send.UnstoppableDomains.simpleplaceholder", value: "Enter domain", comment: "Enter domain") + static let enterA = Localization(key: "Send.UnstoppableDomains.enterA", value: "Enter a", comment: "Enter a") + static let domain = Localization(key: "Send.UnstoppableDomains.domain", value: "domain", comment: "domain") + static let lookup = Localization(key: "Send.UnstoppableDomains.lookup", value: "Lookup", comment: "Lookup") + static let lookupFailureHeader = Localization(key: "Send.UnstoppableDomains.lookupFailureHeader", value: "LookupFailureHeader", comment: "lookupFailureHeader") + static let lookupDomainError = Localization(key: "Send.UnstoppableDomains.lookupDomainError", value: "LookupDomainError", comment: "LookupDomainError") + static let udSystemError = Localization(key: "Send.UnstoppableDomains.udSystemError", value: "UDSystemError", comment: "UDSystemError") + } + } + + enum Receive { + static let title = Localization(key: "Receive.title", value: "Receive", comment: "Receive modal title") + static let emailButton = Localization(key: "Receive.emailButton", value: "Email", comment: "Share via email button label") + static let textButton = Localization(key: "Receive.textButton", value: "Text Message", comment: "Share via text message (SMS)") + static let copied = Localization(key: "Receive.copied", value: "Copied to clipboard.", comment: "Address copied message.") + static let share = Localization(key: "Receive.share", value: "Share", comment: "Share button label") + static let request = Localization(key: "Receive.request", value: "Request an Amount", comment: "Request button label") + static let barItemTitle = Localization(key: "Receive.barItemTitle", value: "Receive", comment: "Receive Bar Item Title") + } + + // MARK: - Litewallet + + enum Litewallet { + static let name = Localization(key: "Litewallet.name", value: "Litewallet", comment: "Litewallet name") + } + + enum Account { + static let loadingMessage = Localization(key: "Account.loadingMessage", value: "Loading Wallet", comment: "Loading Wallet Message") + } + + enum History { + static let barItemTitle = Localization(key: "History.barItemTitle", value: "History", comment: "History Bar Item Title") + static let currentLitecoinValue = Localization(key: "History.currentLitecoinValue", value: "History CurrentLitecoinValue", comment: "History Current Litecoin Value") + } + + enum JailbreakWarnings { + static let title = Localization(key: "JailbreakWarnings.title", value: "WARNING", comment: "Jailbreak warning title") + static let messageWithBalance = Localization(key: "JailbreakWarnings.messageWithBalance", value: "DEVICE SECURITY COMPROMISED\n Any 'jailbreak' app can access Litewallet's keychain data and steal your Litecoin! Wipe this wallet immediately and restore on a secure device.", comment: "Jailbreak warning message") + static let messageWithoutBalance = Localization(key: "JailbreakWarnings.messageWithoutBalance", value: "DEVICE SECURITY COMPROMISED\n Any 'jailbreak' app can access Litewallet's keychain data and steal your Litecoin. Please only use Litewallet on a non-jailbroken device.", comment: "Jailbreak warning message") + static let ignore = Localization(key: "JailbreakWarnings.ignore", value: "Ignore", comment: "Ignore jailbreak warning button") + static let wipe = Localization(key: "JailbreakWarnings.wipe", value: "Wipe", comment: "Wipe wallet button") + static let close = Localization(key: "JailbreakWarnings.close", value: "Close", comment: "Close app button") + } + + enum ErrorMessages { + static let emailUnavailableTitle = Localization(key: "ErrorMessages.emailUnavailableTitle", value: "Email Unavailable", comment: "Email unavailable alert title") + static let emailUnavailableMessage = Localization(key: "ErrorMessages.emailUnavailableMessage", value: "This device isn't configured to send email with the iOS mail app.", comment: "Email unavailable alert title") + static let messagingUnavailableTitle = Localization(key: "ErrorMessages.messagingUnavailableTitle", value: "Messaging Unavailable", comment: "Messaging unavailable alert title") + static let messagingUnavailableMessage = Localization(key: "ErrorMessages.messagingUnavailableMessage", value: "This device isn't configured to send messages.", comment: "Messaging unavailable alert title") + } + + enum UnlockScreen { + static let myAddress = Localization(key: "UnlockScreen.myAddress", value: "My Address", comment: "My Address button title") + static let scan = Localization(key: "UnlockScreen.scan", value: "Scan", comment: "Scan button title") + static let touchIdText = Localization(key: "UnlockScreen.touchIdText", value: "Unlock with TouchID", comment: "Unlock with TouchID accessibility label") + static let touchIdPrompt = Localization(key: "UnlockScreen.touchIdPrompt", value: "Unlock your Litewallet.", comment: "TouchID/FaceID prompt text") + static let enterPIN = Localization(key: "UnlockScreen.enterPin", value: "Enter PIN", comment: "Unlock Screen sub-header") + static let unlocked = Localization(key: "UnlockScreen.unlocked", value: "Wallet Unlocked", comment: "Wallet unlocked message") + static let disabled = Localization(key: "UnlockScreen.disabled", value: "Disabled until: %1$@", comment: "Disabled until date") + static let resetPin = Localization(key: "UnlockScreen.resetPin", value: "Reset PIN", comment: "Reset PIN with Paper Key button label.") + static let faceIdText = Localization(key: "UnlockScreen.faceIdText", value: "Unlock with FaceID", comment: "Unlock with FaceID accessibility label") + } + + enum Transaction { + static let justNow = Localization(key: "Transaction.justNow", value: "just now", comment: "Timestamp label for event that just happened") + static let invalid = Localization(key: "Transaction.invalid", value: "INVALID", comment: "Invalid transaction") + static let complete = Localization(key: "Transaction.complete", value: "Complete", comment: "Transaction complete label") + static let waiting = Localization(key: "Transaction.waiting", value: "Waiting to be confirmed. Some merchants require confirmation to complete a transaction. Estimated time: 1-2 hours.", comment: "Waiting to be confirmed string") + static let starting = Localization(key: "Transaction.starting", value: "Starting balance: %1$@", comment: "eg. Starting balance: $50.00") + static let fee = Localization(key: "Transaction.fee", value: "(%1$@ fee)", comment: "(b600 fee)") + static let ending = Localization(key: "Transaction.ending", value: "Ending balance: %1$@", comment: "eg. Ending balance: $50.00") + static let exchangeOnDaySent = Localization(key: "Transaction.exchangeOnDaySent", value: "Exchange rate when sent:", comment: "Exchange rate on date header") + static let exchangeOnDayReceived = Localization(key: "Transaction.exchangeOnDayReceived", value: "Exchange rate when received:", comment: "Exchange rate on date header") + static let receivedStatus = Localization(key: "Transaction.receivedStatus", value: "In progress: %1$@", comment: "Receive status text: 'In progress: 20%'") + static let sendingStatus = Localization(key: "Transaction.sendingStatus", value: "In progress: %1$@", comment: "Send status text: 'In progress: 20%'") + static let available = Localization(key: "Transaction.available", value: "Available to Spend", comment: "Availability status text") + static let txIDLabel = Localization(key: "Transaction.txIDLabel", value: "Transaction txID", comment: "Static TX iD Label") + static let amountDetailLabel = Localization(key: "Transaction.amountDetailLabel", value: "Transaction amount detail", comment: "Static amount Label") + static let startingAmountDetailLabel = Localization(key: "Transaction.startingAmountDetailLabel", value: "Transaction starting amount detail", comment: "Static starting amount Label") + static let endAmountDetailLabel = Localization(key: "Transaction.endAmountDetailLabel", value: "Transaction end amount detail", comment: "Static end amount Label") + static let blockHeightLabel = Localization(key: "Transaction.blockHeightLabel", value: "Transaction blockHeightLabel", comment: "Static blockHeight Label") + static let commentLabel = Localization(key: "Transaction.commentLabel", value: "Transaction comment label", comment: "Static comment Label") + } + + enum TransactionDetails { + static let title = Localization(key: "TransactionDetails.title", value: "Transaction Details", comment: "Transaction Details Title") + static let receiveModaltitle = Localization(key: "TransactionDetails.receivedModalTitle", value: "RECEIVE LTC", comment: "RECEIVE LTCTitle") + static let statusHeader = Localization(key: "TransactionDetails.statusHeader", value: "Status", comment: "Status section header") + static let commentsHeader = Localization(key: "TransactionDetails.commentsHeader", value: "Memo", comment: "Memo section header") + static let amountHeader = Localization(key: "TransactionDetails.amountHeader", value: "Amount", comment: "Amount section header") + static let emptyMessage = Localization(key: "TransactionDetails.emptyMessage", value: "Your transactions will appear here.", comment: "Empty transaction list message.") + static let txHashHeader = Localization(key: "TransactionDetails.txHashHeader", value: "Litecoin Transaction ID", comment: "Transaction ID header") + static let sentAmountDescription = Localization(key: "TransactionDetails.sentAmountDescription", value: "Sent %1@", comment: "Sent $5.00") + static let receivedAmountDescription = Localization(key: "TransactionDetails.receivedAmountDescription", value: "Received %1@", comment: "Received $5.00") + static let movedAmountDescription = Localization(key: "TransactionDetails.movedAmountDescription", value: "Moved %1@", comment: "Moved $5.00") + static let account = Localization(key: "TransactionDetails.account", value: "account", comment: "e.g. I received money from an account.") + static let sent = Localization(key: "TransactionDetails.sent", value: "Sent %1$@", comment: "Sent $5.00 (sent title 1/2)") + static let received = Localization(key: "TransactionDetails.received", value: "Received %1$@", comment: "Received $5.00 (received title 1/2)") + static let moved = Localization(key: "TransactionDetails.moved", value: "Moved %1$@", comment: "Moved $5.00") + static let to = Localization(key: "TransactionDetails.to", value: "to %1$@", comment: "[sent] to
(sent title 2/2)") + static let from = Localization(key: "TransactionDetails.from", value: "at %1$@", comment: "[received] at
(received title 2/2)") + static let blockHeightLabel = Localization(key: "TransactionDetails.blockHeightLabel", value: "Confirmed in Block", comment: "Block height label") + static let notConfirmedBlockHeightLabel = Localization(key: "TransactionDetails.notConfirmedBlockHeightLabel", value: "Not Confirmed", comment: "eg. Confirmed in Block: Not Confirmed") + static let staticTXIDLabel = Localization(key: "TransactionDetails.staticTXLabel", value: "TXID:", comment: "Label for TXID") + static let priceTimeStampLabel = Localization(key: "TransactionDetails.priceTimeStampPrefix", value: "as of", comment: "Prefix for price") + static let copyAllDetails = Localization(key: "TransactionDetails.copyAllDetails", value: "Copy all details", comment: "Copy all details") + static let copiedAll = Localization(key: "TransactionDetails.copiedAll", value: "Copied", comment: "Copied") + } + + // MARK: - Buy Center + + enum BuyCenter { + static let title = Localization(key: "BuyCenter.title", value: "Buy Litecoin", comment: "Buy Center Title") + static let buyModalTitle = Localization(key: "BuyCenter.ModalTitle", value: "Buy Łitecoin", comment: "Buy Modal Title") + enum Cells { + static let moonpayTitle = Localization(key: "BuyCenter.moonpayTitle", value: "Moonpay", comment: "Moonpay Title") + static let moonpayFinancialDetails = Localization(key: "BuyCenter.moonpayFinancialDetails", value: "• Point 1 XXXXX\n• Point 2 XXXXn• XXX Point 3", comment: "Moonpay buy financial details") + static let simplexTitle = Localization(key: "BuyCenter.simplexTitle", value: "Simplex", comment: "Simplex Title") + static let simplexFinancialDetails = Localization(key: "BuyCenter.simplexFinancialDetails", value: "• Get Litecoin in 5 mins!\n• Buy Litecoin via credit card\n• Passport or State ID", comment: "Simplex buy financial details") + static let changellyTitle = Localization(key: "BuyCenter.changellyTitle", value: "Changelly", comment: "Changelly Title") + static let changellyFinancialDetails = Localization(key: "BuyCenter.changellyFinancialDetails", value: "• Change Litecoin for other cryptos\n• No ID Required\n• Buy via credit card\n• Global coverage", comment: "Changelly buy financial details") + static let bitrefillTitle = Localization(key: "BuyCenter.BitrefillTitle", value: "Bitrefill", comment: "Bitrefill Title") + static let bitrefillFinancialDetails = Localization(key: "BuyCenter.bitrefillFinancialDetails", value: "• Buy gift cards\n• Refill prepaid phones\n• Steam, Amazon, Hotels.com\n• Works in 170 countries", comment: "Bitrefill buy financial details") + } + + static let barItemTitle = Localization(key: "BuyCenter.barItemTitle", value: "Buy", comment: "Buy Bar Item Title") + } + + // MARK: - Security Center + + enum SecurityCenter { + static let title = Localization(key: "SecurityCenter.title", value: "Security Center", comment: "Security Center Title") + static let info = Localization(key: "SecurityCenter.info", value: "Enable all security features for maximum protection.", comment: "Security Center Info") + enum Cells { + static let pinTitle = Localization(key: "SecurityCenter.pinTitle", value: "6-Digit PIN", comment: "PIN button title") + static let pinDescription = Localization(key: "SecurityCenter.pinDescription", value: "Protects your Litewallet from unauthorized users.", comment: "PIN button description") + static let touchIdTitle = Localization(key: "SecurityCenter.touchIdTitle", value: "Touch ID", comment: "Touch ID button title") + static let touchIdDescription = Localization(key: "SecurityCenter.touchIdDescription", value: "Conveniently unlock your Litewallet and send money up to a set limit.", comment: "Touch ID/FaceID button description") + static let paperKeyTitle = Localization(key: "SecurityCenter.paperKeyTitle", value: "Paper Key", comment: "Paper Key button title") + static let paperKeyDescription = Localization(key: "SecurityCenter.paperKeyDescription", value: "The only way to access your Litecoin if you lose or upgrade your phone.", comment: "Paper Key button description") + static let faceIdTitle = Localization(key: "SecurityCenter.faceIdTitle", value: "Face ID", comment: "Face ID button title") + } + } + + enum UpdatePin { + static let updateTitle = Localization(key: "UpdatePin.updateTitle", value: "Update PIN", comment: "Update PIN title") + static let createTitle = Localization(key: "UpdatePin.createTitle", value: "Set PIN", comment: "Update PIN title") + static let createTitleConfirm = Localization(key: "UpdatePin.createTitleConfirm", value: "Re-Enter PIN", comment: "Update PIN title") + static let createInstruction = Localization(key: "UpdatePin.createInstruction", value: "Your PIN will be used to unlock your Litewallet and send money.", comment: "PIN creation info.") + static let enterCurrent = Localization(key: "UpdatePin.enterCurrent", value: "Enter your current PIN.", comment: "Enter current PIN instruction") + static let enterNew = Localization(key: "UpdatePin.enterNew", value: "Enter your new PIN.", comment: "Enter new PIN instruction") + static let reEnterNew = Localization(key: "UpdatePin.reEnterNew", value: "Re-Enter your new PIN.", comment: "Re-Enter new PIN instruction") + static let caption = Localization(key: "UpdatePin.caption", value: "Remember this PIN. If you forget it, you won't be able to access your Litecoin.", comment: "Update PIN caption text") + static let setPinErrorTitle = Localization(key: "UpdatePin.setPinErrorTitle", value: "Update PIN Error", comment: "Update PIN failure alert view title") + static let setPinError = Localization(key: "UpdatePin.setPinError", value: "Sorry, could not update PIN.", comment: "Update PIN failure error message.") + } + + enum RecoverWallet { + static let next = Localization(key: "RecoverWallet.next", value: "Next", comment: "Next button label") + static let intro = Localization(key: "RecoverWallet.intro", value: "Recover your Litewallet with your paper key.", comment: "Recover wallet intro") + static let leftArrow = Localization(key: "RecoverWallet.leftArrow", value: "Left Arrow", comment: "Previous button accessibility label") + static let rightArrow = Localization(key: "RecoverWallet.rightArrow", value: "Right Arrow", comment: "Next button accessibility label") + static let done = Localization(key: "RecoverWallet.done", value: "Done", comment: "Done button text") + static let instruction = Localization(key: "RecoverWallet.instruction", value: "Enter Paper Key", comment: "Enter paper key instruction") + static let header = Localization(key: "RecoverWallet.header", value: "Recover Wallet", comment: "Recover wallet header") + static let subheader = Localization(key: "RecoverWallet.subheader", value: "Enter the paper key for the wallet you want to recover.", comment: "Recover wallet sub-header") + + static let headerResetPin = Localization(key: "RecoverWallet.header_reset_pin", value: "Reset PIN", comment: "Reset PIN with paper key: header") + static let subheaderResetPin = Localization(key: "RecoverWallet.subheader_reset_pin", value: "To reset your PIN, enter the words from your paper key into the boxes below.", comment: "Reset PIN with paper key: sub-header") + static let resetPinInfo = Localization(key: "RecoverWallet.reset_pin_more_info", value: "Tap here for more information.", comment: "Reset PIN with paper key: more information button.") + static let invalid = Localization(key: "RecoverWallet.invalid", value: "The paper key you entered is invalid. Please double-check each word and try again.", comment: "Invalid paper key message") + } + + enum ManageWallet { + static let title = Localization(key: "ManageWallet.title", value: "Manage Wallet", comment: "Manage wallet modal title") + static let textFieldLabel = Localization(key: "ManageWallet.textFeildLabel", value: "Wallet Name", comment: "Change Wallet name textfield label") + static let description = Localization(key: "ManageWallet.description", value: "Your wallet name only appears in your account transaction history and cannot be seen by anyone else.", comment: "Manage wallet description text") + static let creationDatePrefix = Localization(key: "ManageWallet.creationDatePrefix", value: "You created your wallet on %1$@", comment: "Wallet creation date prefix") + static let balance = Localization(key: "ManageWallet.balance", value: "Balance", comment: "Balance") + } + + enum AccountHeader { + static let defaultWalletName = Localization(key: "AccountHeader.defaultWalletName", value: "My Litewallet", comment: "Default wallet name") + static let manageButtonName = Localization(key: "AccountHeader.manageButtonName", value: "MANAGE", comment: "Manage wallet button title") + } + + enum VerifyPin { + static let title = Localization(key: "VerifyPin.title", value: "PIN Required", comment: "Verify PIN view title") + static let continueBody = Localization(key: "VerifyPin.continueBody", value: "Please enter your PIN to continue.", comment: "Verify PIN view body") + static let authorize = Localization(key: "VerifyPin.authorize", value: "Please enter your PIN to authorize this transaction.", comment: "Verify PIN for transaction view body") + static let touchIdMessage = Localization(key: "VerifyPin.touchIdMessage", value: "Authorize this transaction", comment: "Authorize transaction with touch id message") + } + + enum TouchIdSettings { + static let title = Localization(key: "TouchIdSettings.title", value: "Touch ID", comment: "Touch ID settings view title") + static let label = Localization(key: "TouchIdSettings.label", value: "Use your fingerprint to unlock your Litewallet and send money up to a set limit.", comment: "Touch Id screen label") + static let switchLabel = Localization(key: "TouchIdSettings.switchLabel", value: "Enable Touch ID for Litewallet", comment: "Touch id switch label.") + static let unavailableAlertTitle = Localization(key: "TouchIdSettings.unavailableAlertTitle", value: "Touch ID Not Set Up", comment: "Touch ID unavailable alert title") + static let unavailableAlertMessage = Localization(key: "TouchIdSettings.unavailableAlertMessage", value: "You have not set up Touch ID on this device. Go to Settings->Touch ID & Passcode to set it up now.", comment: "Touch ID unavailable alert message") + static let spendingLimit = Localization(key: "TouchIdSettings.spendingLimit", value: "Spending limit: %1$@ (%2$@)", comment: "Spending Limit: b100,000 ($100)") + static let limitValue = Localization(key: "TouchIdSettings.limitValue", value: "%1$@ (%2$@)", comment: " ł100,000 ($100)") + static let customizeText = Localization(key: "TouchIdSettings.customizeText", value: "You can customize your Touch ID spending limit from the %1$@.", comment: "You can customize your Touch ID Spending Limit from the [TouchIdSettings.linkText gets added here as a button]") + static let linkText = Localization(key: "TouchIdSettings.linkText", value: "Touch ID Spending Limit Screen", comment: "Link Text (see TouchIdSettings.customizeText)") + } + + enum FaceIDSettings { + static let title = Localization(key: "FaceIDSettings.title", value: "Face ID", comment: "Face ID settings view title") + static let label = Localization(key: "FaceIDSettings.label", value: "Use your face to unlock your Litewallet and send money up to a set limit.", comment: "Face ID screen label") + static let switchLabel = Localization(key: "FaceIDSettings.switchLabel", value: "Enable Face ID for Litewallet", comment: "Face id switch label.") + static let unavailableAlertTitle = Localization(key: "FaceIDSettings.unavailableAlertTitle", value: "Face ID Not Set Up", comment: "Face ID unavailable alert title") + static let unavailableAlertMessage = Localization(key: "FaceIDSettings.unavailableAlertMessage", value: "You have not set up Face ID on this device. Go to Settings->Face ID & Passcode to set it up now.", comment: "Face ID unavailable alert message") + static let customizeText = Localization(key: "FaceIDSettings.customizeText", value: "You can customize your Face ID spending limit from the %1$@.", comment: "You can customize your Face ID Spending Limit from the [TouchIdSettings.linkText gets added here as a button]") + static let linkText = Localization(key: "FaceIDSettings.linkText", value: "Face ID Spending Limit Screen", comment: "Link Text (see TouchIdSettings.customizeText)") + } + + enum SpendingLimit { + static let titleLabel = Localization(key: "SpendingLimit.title", value: "Current Spending Limit: ", comment: "Current spending limit:") + } + + enum TouchIdSpendingLimit { + static let title = Localization(key: "TouchIdSpendingLimit.title", value: "Touch ID Spending Limit", comment: "Touch Id spending limit screen title") + static let body = Localization(key: "TouchIdSpendingLimit.body", value: "You will be asked to enter your 6-digit PIN to send any transaction over your spending limit, and every 48 hours since the last time you entered your 6-digit PIN.", comment: "Touch ID spending limit screen body") + static let requirePasscode = Localization(key: "TouchIdSpendingLimit", value: "Always require passcode", comment: "Always require passcode option") + } + + enum FaceIdSpendingLimit { + static let title = Localization(key: "FaceIDSpendingLimit.title", value: "Face ID Spending Limit", comment: "Face Id spending limit screen title") + } + + // MARK: - Settings + + enum Settings { + static let title = Localization(key: "Settings.title", value: "Settings", comment: "Settings title") + static let wallet = Localization(key: "Settings.wallet", value: "Wallet", comment: "Wallet Settings section header") + static let manage = Localization(key: "Settings.manage", value: "Manage", comment: "Manage settings section header") + static let support = Localization(key: "Settings.support", value: "Support", comment: "Support settings section header") + static let blockchain = Localization(key: "Settings.blockchain", value: "Blockchain", comment: "Blockchain settings section header") + static let importTile = Localization(key: "Settings.importTitle", value: "Import Wallet", comment: "Import wallet label") + static let notifications = Localization(key: "Settings.notifications", value: "Notifications", comment: "Notifications label") + static let touchIdLimit = Localization(key: "Settings.touchIdLimit", value: "Touch ID Spending Limit", comment: "Touch ID spending limit label") + static let currency = Localization(key: "Settings.currency", value: "Display Currency", comment: "Default currency label") + static let sync = Localization(key: "Settings.sync", value: "Sync Blockchain", comment: "Sync blockchain label") + static let shareData = Localization(key: "Settings.shareData", value: "Share Anonymous Data", comment: "Share anonymous data label") + static let earlyAccess = Localization(key: "Settings.earlyAccess", value: "Join Early Access", comment: "Join Early access label") + static let about = Localization(key: "Settings.about", value: "About", comment: "About label") + static let review = Localization(key: "Settings.review", value: "Leave us a Review", comment: "Leave review button label") + static let enjoying = Localization(key: "Settings.enjoying", value: "Are you enjoying Litewallet?", comment: "Are you enjoying Litewallet alert message body") + static let wipe = Localization(key: "Settings.wipe", value: "Start/Recover Another Wallet", comment: "Start or recover another wallet menu label.") + static let advancedTitle = Localization(key: "Settings.advancedTitle", value: "Advanced Settings", comment: "Advanced Settings title") + static let faceIdLimit = Localization(key: "Settings.faceIdLimit", value: "Face ID Spending Limit", comment: "Face ID spending limit label") + static let languages = Localization(key: "Settings.languages", value: "Languages", comment: "Languages label") + static let litewalletVersion = Localization(key: "Settings.litewallet.version", value: "Litewallet Version:", comment: "Litewallet version") + static let litewalletEnvironment = Localization(key: "Settings.litewallet.environment", value: "Litewallet Environment:", comment: "Litewallet environment") + static let socialLinks = Localization(key: "Settings.socialLinks", value: "Social Links:", comment: "Litewallet Social links") + static let litewalletPartners = Localization(key: "Settings.litewallet.partners", value: "Litewallet Partners:", comment: "Litewallet Partners") + static let currentLocale = Localization(key: "Settings.currentLocale", value: "Current Locale:", comment: "Current Locale") + static let changeLanguageMessage = Localization(key: "Settings.ChangeLanguage.alertMessage", value: nil, comment: nil) + } + + enum About { + static let title = Localization(key: "About.title", value: "About", comment: "About screen title") + static let blog = Localization(key: "About.blog", value: "Website", comment: "About screen website label") + static let twitter = Localization(key: "About.twitter", value: "Twitter", comment: "About screen twitter label") + static let reddit = Localization(key: "About.reddit", value: "Reddit", comment: "About screen reddit label") + static let privacy = Localization(key: "About.privacy", value: "Privacy Policy", comment: "Privay Policy button label") + static let footer = Localization(key: "About.footer", value: "Made by the LiteWallet Team\nof the\nLitecoin Foundation\n%1$@", comment: "About screen footer") + } + + enum Notifications { + static let emailTitle = Localization(key: "Notifications.emailTitle", value: "Don't a miss a thing!", comment: "Email title") + static let pitchMessage = Localization(key: "Notifications.pitchMessage", value: "Sign up to hear about updates & contests from the Litewallet team!\nAccept notifications to get live news, price & market information!", comment: "Pitch to get user to sign up") + static let emailLabel = Localization(key: "Notifications.emailLabel", value: "Email address", comment: "Email address label") + static let emailPlaceholder = Localization(key: "Notifications.emailPlaceholder", value: "Enter here", comment: "Email address placeholder") + static let languagePreference = Localization(key: "Notifications.languagePreference", value: "Preferred language:", comment: "Language preference label") + static let signupCancel = Localization(key: "Notifications.signupCancel", value: "No, thanks", comment: "Signup cancel") + } + + enum DefaultCurrency { + static let rateLabel = Localization(key: "DefaultCurrency.rateLabel", value: "Exchange Rate", comment: "Exchange rate label") + static let bitcoinLabel = Localization(key: "DefaultCurrency.bitcoinLabel", value: "Litecoin Display Unit", comment: "Litecoin denomination picker label") + static let chooseFiatLabel = Localization(key: "DefaultCurrency.chooseFiatLabel", value: "Choose Fiat:", comment: "Label to pick fiat") + } + + enum SyncingView { + static let syncing = Localization(key: "SyncingView.syncing", value: "Syncing", comment: "Syncing view syncing state header text") + static let connecting = Localization(key: "SyncingView.connecting", value: "Connecting", comment: "Syncing view connectiong state header text") + } + + enum SyncingHeader { + static let syncing = Localization(key: "SyncingHeader.syncing", value: "Syncing...", comment: "Syncing view syncing state header text") + static let connecting = Localization(key: "SyncingHeader.connecting", value: "Connecting...", comment: "Syncing view connection state header text") + static let success = Localization(key: "SyncingHeader.success", value: "Success!", comment: "Syncing header success state header text") + static let rescanning = Localization(key: "SyncingHeader.rescan", value: "Rescanning...*", comment: "Rescanning header success state header text") + } + + enum ReScan { + static let header = Localization(key: "ReScan.header", value: "Sync Blockchain", comment: "Sync Blockchain view header") + static let subheader1 = Localization(key: "ReScan.subheader1", value: "Estimated time", comment: "Subheader label") + static let subheader2 = Localization(key: "ReScan.subheader2", value: "When to Sync?", comment: "Subheader label") + static let body1 = Localization(key: "ReScan.body1", value: "20-45 minutes", comment: "extimated time") + static let body2 = Localization(key: "ReScan.body2", value: "If a transaction shows as completed on the Litecoin network but not in your Litewallet.", comment: "Syncing explanation") + static let body3 = Localization(key: "ReScan.body3", value: "You repeatedly get an error saying your transaction was rejected.", comment: "Syncing explanation") + static let buttonTitle = Localization(key: "ReScan.buttonTitle", value: "Start Sync", comment: "Start Sync button label") + static let footer = Localization(key: "ReScan.footer", value: "You will not be able to send money while syncing with the blockchain.", comment: "Sync blockchain view footer") + static let alertTitle = Localization(key: "ReScan.alertTitle", value: "Sync with Blockchain?", comment: "Alert message title") + static let alertMessage = Localization(key: "ReScan.alertMessage", value: "You will not be able to send money while syncing.", comment: "Alert message body") + static let alertAction = Localization(key: "ReScan.alertAction", value: "Sync", comment: "Alert action button label") + } + + enum ShareData { + static let header = Localization(key: "ShareData.header", value: "Share Data?", comment: "Share data header") + static let body = Localization(key: "ShareData.body", value: "Help improve Litewallet by sharing your anonymous data with us. This does not include any financial information. We respect your financial privacy.", comment: "Share data view body") + static let toggleLabel = Localization(key: "ShareData.toggleLabel", value: "Share Anonymous Data?", comment: "Share data switch label.") + } + + enum ConfirmPaperPhrase { + static let word = Localization(key: "ConfirmPaperPhrase.word", value: "Word #%1$@", comment: "Word label eg. Word #1, Word #2") + static let label = Localization(key: "ConfirmPaperPhrase.label", value: "To make sure everything was written down correctly, please enter the following words from your paper key.", comment: "Confirm paper phrase view label.") + static let error = Localization(key: "ConfirmPaperPhrase.error", value: "The words entered do not match your paper key. Please try again.", comment: "Confirm paper phrase error message") + } + + enum StartPaperPhrase { + static let body = Localization(key: "StartPaperPhrase.body", value: "Your paper key is the only way to restore your Litewallet if your mobile is unavailable.\n No one in the Litecoin Foundation team can give this paper key to you!\n\nWe will show you a list of words to write down on a piece of paper and keep safe.\n\nPLEASE MAKE BACKUPS AND DON'T LOSE IT!", comment: "Paper key explanation text.") + static let buttonTitle = Localization(key: "StartPaperPhrase.buttonTitle", value: "Write Down Paper Key", comment: "button label") + static let againButtonTitle = Localization(key: "StartPaperPhrase.againButtonTitle", value: "Write Down Paper Key Again", comment: "button label") + static let date = Localization(key: "StartPaperPhrase.date", value: "You last wrote down your paper key on %1$@", comment: "Argument is date") + } + + enum WritePaperPhrase { + static let instruction = Localization(key: "WritePaperPhrase.instruction", value: "Write down each word in order and store it in a safe place.", comment: "Paper key instructions.") + static let step = Localization(key: "WritePaperPhrase.step", value: "%1$d of %2$d", comment: "1 of 3") + static let next = Localization(key: "WritePaperPhrase.next", value: "Next", comment: "button label") + static let previous = Localization(key: "WritePaperPhrase.previous", value: "Previous", comment: "button label") + } + + enum TransactionDirection { + static let to = Localization(key: "TransactionDirection.to", value: "Sent to this Address", comment: "(this transaction was) Sent to this address:") + static let received = Localization(key: "TransactionDirection.address", value: "Received at this Address", comment: "(this transaction was) Received at this address:") + } + + enum RequestAnAmount { + static let title = Localization(key: "RequestAnAmount.title", value: "Request an Amount", comment: "Request a specific amount of Litecoin") + static let noAmount = Localization(key: "RequestAnAmount.noAmount", value: "Please enter an amount first.", comment: "No amount entered error message.") + } + + // MARK: - Security Alerts + + enum SecurityAlerts { + static let pinSet = Localization(key: "Alerts.pinSet", value: "PIN Set", comment: "Alert Header label (the PIN was set)") + static let paperKeySet = Localization(key: "Alerts.paperKeySet", value: "Paper Key Set", comment: "Alert Header Label (the paper key was set)") + static let sendSuccess = Localization(key: "Alerts.sendSuccess", value: "Send Confirmation", comment: "Send success alert header label (confirmation that the send happened)") + static let resolvedSuccess = Localization(key: "Alerts.resolvedSuccess", value: "Resolved Success", comment: "Resolved Success") + static let resolvedSuccessSubheader = Localization(key: "Alerts.resolvedSuccessSubheader", value: "Resolved", comment: "Resolved Success subheader") + static let sendFailure = Localization(key: "Alerts.sendFailure", value: "Send failed", comment: "Send failure alert header label (the send failed to happen)") + static let paperKeySetSubheader = Localization(key: "Alerts.paperKeySetSubheader", value: "Awesome!", comment: "Alert Subheader label (playfully positive)") + static let sendSuccessSubheader = Localization(key: "Alerts.sendSuccessSubheader", value: "Money Sent!", comment: "Send success alert subheader label (e.g. the money was sent)") + static let copiedAddressesHeader = Localization(key: "Alerts.copiedAddressesHeader", value: "Addresses Copied", comment: "'the addresses were copied'' Alert title") + static let copiedAddressesSubheader = Localization(key: "Alerts.copiedAddressesSubheader", value: "All wallet addresses successfully copied.", comment: "Addresses Copied Alert sub header") + } + + enum MenuButton { + static let security = Localization(key: "MenuButton.security", value: "Security Center", comment: "Menu button title") + static let support = Localization(key: "MenuButton.customer.support", value: "Customer support", comment: "Menu button title") + static let settings = Localization(key: "MenuButton.settings", value: "Settings", comment: "Menu button title") + static let lock = Localization(key: "MenuButton.lock", value: "Lock Wallet", comment: "Menu button title") + static let buy = Localization(key: "MenuButton.buy", value: "Buy Litecoin", comment: "Buy Litecoin title") + } + + enum MenuViewController { + static let modalTitle = Localization(key: "MenuViewController.modalTitle", value: "Menu", comment: "Menu modal title") + } + + enum StartViewController { + static let createButton = Localization(key: "MenuViewController.createButton", value: "Create New Wallet", comment: "button label") + static let recoverButton = Localization(key: "MenuViewController.recoverButton", value: "Recover Wallet", comment: "button label") + static let tagline = Localization(key: "StartViewController.tagline", value: "The most secure and safest way to use Litecoin.", comment: "Start view message") + static let continueButton = Localization(key: "StartViewController.continueButton", value: "Continue", comment: "Continue button label") + } + + enum AccessibilityLabels { + static let close = Localization(key: "AccessibilityLabels.close", value: "Close", comment: "Close modal button accessibility label") + static let faq = Localization(key: "AccessibilityLabels.faq", value: "Support Center", comment: "Support center accessibiliy label") + } + + enum Search { + static let sent = Localization(key: "Search.sent", value: "sent", comment: "Sent filter label") + static let received = Localization(key: "Search.received", value: "received", comment: "Received filter label") + static let pending = Localization(key: "Search.pending", value: "pending", comment: "Pending filter label") + static let complete = Localization(key: "Search.complete", value: "complete", comment: "Complete filter label") + } + + enum Prompts { + static let affirm = Localization(key: "Prompts.PaperKey.affirm", value: "Continue", comment: "Affirm button title.") + static let cancel = Localization(key: "Prompts.PaperKey.cancel", value: "Cancel", comment: "Cancel button.") + static let enable = Localization(key: "Prompts.PaperKey.enable", value: "Enable", comment: "Enable button.") + static let dismiss = Localization(key: "Prompts.dismiss", value: "**Dismiss", comment: "Dismiss button.") + enum TouchId { + static let title = Localization(key: "Prompts.TouchId.title", value: "Enable Touch ID", comment: "Enable touch ID prompt title") + static let body = Localization(key: "Prompts.TouchId.body", value: "Tap here to enable Touch ID", comment: "Enable touch ID prompt body") + } + + enum PaperKey { + static let title = Localization(key: "Prompts.PaperKey.title", value: "Action Required", comment: "An action is required (You must do this action).") + static let body = Localization(key: "Prompts.PaperKey.body", value: "Your Paper Key must be kept in a safe place. It is the only way modify or restore your Litewallet or transfer your Litecoin. Please write it down.", comment: "Warning about paper key.") + } + + enum SetPin { + static let title = Localization(key: "Prompts.SetPin.title", value: "Set PIN", comment: "Set PIN prompt title.") + static let body = Localization(key: "Prompts.SetPin.body", value: "Litewallet requires a 6-digit PIN. Please set and store your PIN in a safe place.", comment: "Upgrade PIN prompt body.") + } + + enum RecommendRescan { + static let title = Localization(key: "Prompts.RecommendRescan.title", value: "Transaction Rejected", comment: "Transaction rejected prompt title") + static let body = Localization(key: "Prompts.RecommendRescan.body", value: "Your wallet may be out of sync. This can often be fixed by rescanning the blockchain.", comment: "Transaction rejected prompt body") + } + + enum NoPasscode { + static let title = Localization(key: "Prompts.NoPasscode.title", value: "Turn device passcode on", comment: "No Passcode set warning title") + static let body = Localization(key: "Prompts.NoPasscode.body", value: "A device passcode is needed to safeguard your wallet.", comment: "No passcode set warning body") + } + + enum ShareData { + static let title = Localization(key: "Prompts.ShareData.title", value: "Share Anonymous Data", comment: "Share data prompt title") + static let body = Localization(key: "Prompts.ShareData.body", value: "Help improve Litewallet by sharing your anonymous data with us", comment: "Share data prompt body") + } + + enum FaceId { + static let title = Localization(key: "Prompts.FaceId.title", value: "Enable Face ID", comment: "Enable face ID prompt title") + static let body = Localization(key: "Prompts.FaceId.body", value: "Tap here to enable Face ID", comment: "Enable face ID prompt body") + } + } + + // MARK: - Payment Protocol + + enum PaymentProtocol { + enum Errors { + static let untrustedCertificate = Localization(key: "PaymentProtocol.Errors.untrustedCertificate", value: "untrusted certificate", comment: "Untrusted certificate payment protocol error message") + static let missingCertificate = Localization(key: "PaymentProtocol.Errors.missingCertificate", value: "missing certificate", comment: "Missing certificate payment protocol error message") + static let unsupportedSignatureType = Localization(key: "PaymentProtocol.Errors.unsupportedSignatureType", value: "unsupported signature type", comment: "Unsupported signature type payment protocol error message") + static let requestExpired = Localization(key: "PaymentProtocol.Errors.requestExpired", value: "request expired", comment: "Request expired payment protocol error message") + static let badPaymentRequest = Localization(key: "PaymentProtocol.Errors.badPaymentRequest", value: "Bad Payment Request", comment: "Bad Payment request alert title") + static let smallOutputErrorTitle = Localization(key: "PaymentProtocol.Errors.smallOutputError", value: "Couldn't make payment", comment: "Payment too small alert title") + static let smallPayment = Localization(key: "PaymentProtocol.Errors.smallPayment", value: "Litecoin payments can't be less than %1$@.", comment: "Amount too small error message") + static let smallTransaction = Localization(key: "PaymentProtocol.Errors.smallTransaction", value: "Litecoin transaction outputs can't be less than $@.", comment: "Output too small error message.") + static let corruptedDocument = Localization(key: "PaymentProtocol.Errors.corruptedDocument", value: "Unsupported or corrupted document", comment: "Error opening payment protocol file message") + } + } + + enum URLHandling { + static let addressListAlertTitle = Localization(key: "URLHandling.addressListAlertTitle", value: "Copy Wallet Addresses", comment: "Authorize to copy wallet address alert title") + static let addressListAlertMessage = Localization(key: "URLHandling.addressaddressListAlertMessage", value: "Copy wallet addresses to clipboard?", comment: "Authorize to copy wallet addresses alert message") + static let addressListVerifyPrompt = Localization(key: "URLHandling.addressList", value: "Authorize to copy wallet address to clipboard", comment: "Authorize to copy wallet address PIN view prompt.") + static let copy = Localization(key: "URLHandling.copy", value: "Copy", comment: "Copy wallet addresses alert button label") + } + + enum ApiClient { + static let notReady = Localization(key: "ApiClient.notReady", value: "Wallet not ready", comment: "Wallet not ready error message") + static let jsonError = Localization(key: "ApiClient.jsonError", value: "JSON Serialization Error", comment: "JSON Serialization error message") + static let tokenError = Localization(key: "ApiClient.tokenError", value: "Unable to retrieve API token", comment: "API Token error message") + } + + enum CameraPlugin { + static let centerInstruction = Localization(key: "CameraPlugin.centerInstruction", value: "Center your ID in the box", comment: "Camera plugin instruction") + } + + enum LocationPlugin { + static let disabled = Localization(key: "LocationPlugin.disabled", value: "Location services are disabled.", comment: "Location services disabled error") + static let notAuthorized = Localization(key: "LocationPlugin.notAuthorized", value: "Litewallet does not have permission to access location services.", comment: "No permissions for location services") + } + + enum Webview { + static let updating = Localization(key: "Webview.updating", value: "Updating...", comment: "Updating webview message") + static let errorMessage = Localization(key: "Webview.errorMessage", value: "There was an error loading the content. Please try again.", comment: "Webview loading error message") + static let dismiss = Localization(key: "Webview.dismiss", value: "Dismiss", comment: "Dismiss button label") + } + + enum TimeSince { + static let seconds = Localization(key: "TimeSince.seconds", value: "%1$@ s", comment: "6 s (6 seconds)") + static let minutes = Localization(key: "TimeSince.minutes", value: "%1$@ m", comment: "6 m (6 minutes)") + static let hours = Localization(key: "TimeSince.hours", value: "%1$@ h", comment: "6 h (6 hours)") + static let days = Localization(key: "TimeSince.days", value: "%1$@ d", comment: "6 d (6 days)") + } + + enum Import { + static let leftCaption = Localization(key: "Import.leftCaption", value: "Wallet to be imported", comment: "Caption for graphics") + static let rightCaption = Localization(key: "Import.rightCaption", value: "Your Litewallet Wallet", comment: "Caption for graphics") + static let importMessage = Localization(key: "Import.message", value: "Importing a wallet transfers all the money from your other wallet into your Litewallet wallet using a single transaction.", comment: "Import wallet intro screen message") + static let importWarning = Localization(key: "Import.warning", value: "Importing a wallet does not include transaction history or other details.", comment: "Import wallet intro warning message") + static let scan = Localization(key: "Import.scan", value: "Scan Private Key", comment: "Scan Private key button label") + static let title = Localization(key: "Import.title", value: "Import Wallet", comment: "Import Wallet screen title") + static let importing = Localization(key: "Import.importing", value: "Importing Wallet", comment: "Importing wallet progress view label") + static let confirm = Localization(key: "Import.confirm", value: "Send %1$@ from this private key into your wallet? The Litecoin network will receive a fee of %2$@.", comment: "Sweep private key confirmation message") + static let checking = Localization(key: "Import.checking", value: "Checking private key balance...", comment: "Checking private key balance progress view text") + static let password = Localization(key: "Import.password", value: "This private key is password protected.", comment: "Enter password alert view title") + static let passwordPlaceholder = Localization(key: "Import.passwordPlaceholder", value: "password", comment: "password textfield placeholder") + static let unlockingActivity = Localization(key: "Import.unlockingActivity", value: "Unlocking Key", comment: "Unlocking Private key activity view message.") + static let importButton = Localization(key: "Import.importButton", value: "Import", comment: "Import button label") + static let success = Localization(key: "Import.success", value: "Success", comment: "Import wallet success alert title") + static let successBody = Localization(key: "Import.SuccessBody", value: "Successfully imported wallet.", comment: "Successfully imported wallet message body") + static let wrongPassword = Localization(key: "Import.wrongPassword", value: "Wrong password, please try again.", comment: "Wrong password alert message") + enum Error { + static let notValid = Localization(key: "Import.Error.notValid", value: "Not a valid private key", comment: "Not a valid private key error message") + static let duplicate = Localization(key: "Import.Error.duplicate", value: "This private key is already in your wallet.", comment: "Duplicate key error message") + static let empty = Localization(key: "Import.Error.empty", value: "This private key is empty.", comment: "empty private key error message") + static let highFees = Localization(key: "Import.Error.highFees", value: "Transaction fees would cost more than the funds available on this private key.", comment: "High fees error message") + static let signing = Localization(key: "Import.Error.signing", value: "Error signing transaction", comment: "Import signing error message") + } + } + + enum WipeWallet { + static let title = Localization(key: "WipeWallet.title", value: "Start or Recover Another Wallet", comment: "Wipe wallet navigation item title.") + static let alertTitle = Localization(key: "WipeWallet.alertTitle", value: "Wipe Wallet?", comment: "Wipe wallet alert title") + static let alertMessage = Localization(key: "WipeWallet.alertMessage", value: "Are you sure you want to delete this wallet?", comment: "Wipe wallet alert message") + static let wipe = Localization(key: "WipeWallet.wipe", value: "Wipe", comment: "Wipe wallet button title") + static let wiping = Localization(key: "WipeWallet.wiping", value: "Wiping...", comment: "Wiping activity message") + static let failedTitle = Localization(key: "WipeWallet.failedTitle", value: "Failed", comment: "Failed wipe wallet alert title") + static let failedMessage = Localization(key: "WipeWallet.failedMessage", value: "Failed to wipe wallet.", comment: "Failed wipe wallet alert message") + static let instruction = Localization(key: "WipeWallet.instruction", value: "To start a new wallet or restore an existing wallet, you must first erase the wallet that is currently installed. To continue, enter the current wallet's Paper Key.", comment: "Enter key to wipe wallet instruction.") + static let startMessage = Localization(key: "WipeWallet.startMessage", value: "Starting or recovering another wallet allows you to access and manage a different Litewallet wallet on this device.", comment: "Start wipe wallet view message") + static let startWarning = Localization(key: "WipeWallet.startWarning", value: "Your current wallet will be removed from this device. If you wish to restore it in the future, you will need to enter your Paper Key.", comment: "Start wipe wallet view warning") + static let emptyWallet = Localization(key: "WipeWallet.emptyWallet", value: "Forget seed or PIN?", comment: "Warning if user lost phrase") + static let resetTitle = Localization(key: "resetTitle", value: " Delete my Litewallet ", comment: "Warning Empty Wipe title") + static let resetButton = Localization(key: "resetButton", value: "Yes, reset wallet", comment: "Reset walet button title") + static let warningTitle = Localization(key: "WipeWallet.warningTitle", value: "PLEASE READ!", comment: "Warning title") + static let warningDescription = Localization(key: "WipeWallet.warningDescription", value: "Your LiteWallet is empty. Resetting will delete the old private key and wipe the app data.\n\nAfter the reset, be prepared to record the new 12 words and keep them in a very secure place.\n\nNo LiteWallet developers can retrieve this seed for you.", comment: "Warning description") + static let warningAlert = Localization(key: "WipeWallet.warningAlert", value: "DO NOT LOSE IT!", comment: "Warning Alert") + static let deleteDatabase = Localization(key: "WipeWallet.deleteDatabase", value: "Delete database", comment: "Delete db") + static let alertDeleteTitle = Localization(key: "WipeWallet.alertDeleteTitle", value: "Delet Database", comment: "Delete database title") + static let deleteMessageTitle = Localization(key: "WipeWallet.deleteMessageTitle", value: "This deletes the database but retains the PIN and phrase. You will be asked to confirm your existing PIN, seed and will re-sync the new db", comment: "Delete database message") + static let deleteSync = Localization(key: "WipeWallet.deleteSync", value: "Delete & Sync", comment: "Delete and sync") + } + + enum FeeSelector { + static let title = Localization(key: "FeeSelector.title", value: "Processing Speed", comment: "Fee Selector title") + static let regularLabel = Localization(key: "FeeSelector.regularLabel", value: "Estimated Delivery: 2.5 - 5+ minutes", comment: "Fee Selector regular fee description") + static let economyLabel = Localization(key: "FeeSelector.economyLabel", value: "Estimated Delivery: ~10 minutes", comment: "Fee Selector economy fee description") + static let luxuryLabel = Localization(key: "FeeSelector.luxuryLabel", value: "Delivery: 2.5 - 5+ minutes", comment: "Fee Selector luxury fee description") + static let economyWarning = Localization(key: "FeeSelector.economyWarning", value: "This option is not recommended for time-sensitive transactions.", comment: "Warning message for economy fee") + static let luxuryMessage = Localization(key: "FeeSelector.luxuryMessage", value: "This option virtually guarantees acceptance of your transaction while you pay a premium.", comment: "Message for luxury fee") + + static let regular = Localization(key: "FeeSelector.regular", value: "Regular", comment: "Regular fee") + static let economy = Localization(key: "FeeSelector.economy", value: "Economy", comment: "Economy fee") + static let luxury = Localization(key: "FeeSelector.luxury", value: "Luxury", comment: "Luxury fee") + } + + enum Confirmation { + static let title = Localization(key: "Confirmation.title", value: "Confirmation", comment: "Confirmation Screen title") + static let send = Localization(key: "Confirmation.send", value: "Send", comment: "Send: (amount)") + static let to = Localization(key: "Confirmation.to", value: "To", comment: "To: (address)") + static let staticAddressLabel = Localization(key: "Confirmation.staticAddressLabel", value: "ADDRESS:", comment: "Address label") + + static let processingTime = Localization(key: "Confirmation.processingTime", value: "Processing time: This transaction will take %1$@ minutes to process.", comment: "eg. Processing time: This transaction will take 10-30 minutes to process.") + static let processingAndDonationTime = Localization(key: "Confirmation.processingAndDonationTime", value: "Processing time: These transactions will take %1$@ minutes to process.", comment: "eg. Processing with Donation time: This transaction will take 10-30 minutes to process.") + static let amountLabel = Localization(key: "Confirmation.amountLabel", value: "Amount to Send:", comment: "Amount to Send: ($1.00)") + static let donateLabel = Localization(key: "Confirmation.donateLabel", value: "Amount to Donate:", comment: "Amount to Donate: ($1.00)") + + static let totalLabel = Localization(key: "Confirmation.totalLabel", value: "Total Cost:", comment: "Total Cost: ($5.00)") + static let amountDetailLabel = Localization(key: "Confirmation.amountDetailLabel", value: "Exchange details:", comment: "$53.09/L + 1.07%") + } + + enum NodeSelector { + static let manualButton = Localization(key: "NodeSelector.manualButton", value: "Switch to Manual Mode", comment: "Switch to manual mode button label") + static let automaticButton = Localization(key: "NodeSelector.automaticButton", value: "Switch to Automatic Mode", comment: "Switch to automatic mode button label") + static let title = Localization(key: "NodeSelector.title", value: "Litecoin Nodes", comment: "Node Selector view title") + static let nodeLabel = Localization(key: "NodeSelector.nodeLabel", value: "Current Primary Node", comment: "Node address label") + static let statusLabel = Localization(key: "NodeSelector.statusLabel", value: "Node Connection Status", comment: "Node status label") + static let connected = Localization(key: "NodeSelector.connected", value: "Connected", comment: "Node is connected label") + static let notConnected = Localization(key: "NodeSelector.notConnected", value: "Not Connected", comment: "Node is not connected label") + static let enterTitle = Localization(key: "NodeSelector.enterTitle", value: "Enter Node", comment: "Enter Node ip address view title") + static let enterBody = Localization(key: "NodeSelector.enterBody", value: "Enter Node IP address and port (optional)", comment: "Enter node ip address view body") + } + + enum Welcome { + static let title = Localization(key: "Welcome.title", value: "Welcome to Litewallet", comment: "Welcome view title") + static let body = Localization(key: "Welcome.body", value: "Litewallet now has a brand new look and some new features.\n\nAll coins are displayed in lites (ł). 1 Litecoin (Ł) = 1000 lites (ł).", comment: "Welcome view body text") + } + + enum Fragments { + static let or = Localization(key: "Fragment.or", value: "or", comment: "Or") + static let confirm = Localization(key: "Fragment.confirm", value: "confirm", comment: "Confirm") + static let to = Localization(key: "Fragment.to", value: "to", comment: "to") + static let sorry = Localization(key: "Fragment.sorry", value: "sorry", comment: "sorry") + } +} diff --git a/litewallet/src/Controls/MenuButton.swift b/litewallet/src/Controls/MenuButton.swift new file mode 100644 index 000000000..bf24d6207 --- /dev/null +++ b/litewallet/src/Controls/MenuButton.swift @@ -0,0 +1,67 @@ +import UIKit + +class MenuButton: UIControl { + // MARK: - Public + + let type: MenuButtonType + + init(type: MenuButtonType) { + self.type = type + super.init(frame: .zero) + + if #available(iOS 11.0, *) { + label.textColor = UIColor(named: "labelTextColor") + self.backgroundColor = UIColor(named: "lfBackgroundColor") + image.tintColor = .whiteTint + } + + setupViews() + } + + // MARK: - Private + + private let label = UILabel(font: .customBody(size: 16.0)) + private let image = UIImageView() + private let border = UIView() + + override var isHighlighted: Bool { + didSet { + if isHighlighted { + backgroundColor = .litecoinSilver + } else { + backgroundColor = .white + } + } + } + + private func setupViews() { + addSubview(label) + addSubview(image) + addSubview(border) + + label.constrain([ + label.constraint(.centerY, toView: self, constant: 0.0), + label.constraint(.leading, toView: self, constant: C.padding[2]), + ]) + image.constrain([ + image.constraint(.centerY, toView: self, constant: 0.0), + image.constraint(.trailing, toView: self, constant: -C.padding[4]), + image.constraint(.width, constant: 16.0), + image.constraint(.height, constant: 16.0), + ]) + border.constrainBottomCorners(sidePadding: 0, bottomPadding: 0) + border.constrain([ + border.constraint(.height, constant: 1.0), + ]) + + label.text = type.title + image.image = type.image + image.contentMode = .scaleAspectFit + border.backgroundColor = .secondaryShadow + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Controls/MenuButtonType.swift b/litewallet/src/Controls/MenuButtonType.swift new file mode 100644 index 000000000..97e9bf95d --- /dev/null +++ b/litewallet/src/Controls/MenuButtonType.swift @@ -0,0 +1,34 @@ +import UIKit + +enum MenuButtonType { + case security + case customerSupport + case settings + case lock + + var title: String { + switch self { + case .security: + return S.MenuButton.security.localize() + case .customerSupport: + return S.MenuButton.support.localize() + case .settings: + return S.MenuButton.settings.localize() + case .lock: + return S.MenuButton.lock.localize() + } + } + + var image: UIImage { + switch self { + case .security: + return #imageLiteral(resourceName: "Shield") + case .customerSupport: + return #imageLiteral(resourceName: "FaqFill") + case .settings: + return #imageLiteral(resourceName: "Settings") + case .lock: + return #imageLiteral(resourceName: "Lock") + } + } +} diff --git a/litewallet/src/Controls/SegmentedButton.swift b/litewallet/src/Controls/SegmentedButton.swift new file mode 100644 index 000000000..ef99db7dc --- /dev/null +++ b/litewallet/src/Controls/SegmentedButton.swift @@ -0,0 +1,46 @@ +import UIKit + +enum SegmentedButtonType { + case left + case right +} + +class SegmentedButton: UIControl { + // MARK: - Public + + init(title: String, type: SegmentedButtonType) { + self.title = title + self.type = type + super.init(frame: .zero) + accessibilityLabel = title + setupViews() + } + + // MARK: - Private + + private let title: String + private let type: SegmentedButtonType + private let label = UILabel(font: .customMedium(size: 13.0), color: .white) + + override var isHighlighted: Bool { + didSet { + if isHighlighted { + backgroundColor = UIColor(white: 1.0, alpha: 0.4) + } else { + backgroundColor = .clear + } + } + } + + private func setupViews() { + addSubview(label) + label.constrain(toSuperviewEdges: nil) + label.textAlignment = .center + label.text = title + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Environment.swift b/litewallet/src/Environment.swift new file mode 100644 index 000000000..8541978fa --- /dev/null +++ b/litewallet/src/Environment.swift @@ -0,0 +1,159 @@ +import UIKit + +/// 14 Languages +enum LanguageSelection: Int, CaseIterable, Equatable, Identifiable { + case English = 0 + case ChineseTraditional + case ChineseSimplified + case French + case German + case Indonesian + case Italian + case Japan + case Korean + case Portuguese + case Russian + case Spanish + case Turkish + case Ukrainian + var id: LanguageSelection { self } + + var code: String { + switch self { + case .English: return "en" + case .ChineseTraditional: return "zh" + case .ChineseSimplified: return "zh" + case .French: return "fr" + case .German: return "de" + case .Indonesian: return "id" + case .Italian: return "it" + case .Japan: return "ja" + case .Korean: return "ko" + case .Portuguese: return "pt" + case .Russian: return "ru" + case .Spanish: return "es" + case .Turkish: return "tr" + case .Ukrainian: return "uk" + } + } + + var nativeName: String { + switch self { + case .English: return "English" + case .ChineseTraditional: return "中國人" + case .ChineseSimplified: return "中国人" + case .French: return "Français" + case .German: return "Deutsch" + case .Indonesian: return "Bahasa Indonesia" + case .Italian: return "Italiano" + case .Japan: return "日本語" + case .Korean: return "한국인" + case .Portuguese: return "Português" + case .Russian: return "Русский" + case .Spanish: return "Español" + case .Turkish: return "Türkçe" + case .Ukrainian: return "українська" + } + } + + var voiceFilename: String { + switch self { + case .English: return "English" + case .ChineseTraditional: return "中國人" + case .ChineseSimplified: return "中國人" + case .French: return "Français" + case .German: return "Deutsch" + case .Indonesian: return "BahasaIndonesia" + case .Italian: return "Italiano" + case .Japan: return "日本語" + case .Korean: return "한국인" + case .Portuguese: return "Português" + case .Russian: return "Русский" + case .Spanish: return "Español" + case .Turkish: return "Türkçe" + case .Ukrainian: return "українська" + } + } +} + +struct E { + static let isTestnet: Bool = { + #if Testnet + return true + #else + return false + #endif + }() + + static let isTestFlight: Bool = { + #if Testflight + return true + #else + return false + #endif + }() + + static let isSimulator: Bool = { + #if arch(i386) || arch(x86_64) + return true + #else + return false + #endif + }() + + static let isDebug: Bool = { + #if Debug + return true + #else + return false + #endif + }() + + static let isRelease: Bool = { + #if Release + return true + #else + return false + #endif + }() + + static let isScreenshots: Bool = { + #if Screenshots + return true + #else + return false + #endif + }() + + static var isIPhone4: Bool { + return (UIScreen.main.bounds.size.height == 480.0) + } + + static var isIPhone5: Bool { + return (UIScreen.main.bounds.size.height == 568.0) && (E.is32Bit) + } + + static var isIPhoneX: Bool { + return (UIScreen.main.bounds.size.height == 812.0) + } + + static var isIPhone8Plus: Bool { + return (UIScreen.main.bounds.size.height == 736.0) + } + + static var isIPhoneXsMax: Bool { + return (UIScreen.main.bounds.size.height == 812.0) + } + + static var isIPad: Bool { + return (UIDevice.current.userInterfaceIdiom == .pad) + } + + static let is32Bit: Bool = { + MemoryLayout.size == MemoryLayout.size + }() + + static var screenHeight: CGFloat { + return UIScreen.main.bounds.size.height + } +} diff --git a/litewallet/src/Extensions/ApplicationController+Extension.swift b/litewallet/src/Extensions/ApplicationController+Extension.swift new file mode 100644 index 000000000..5426f1577 --- /dev/null +++ b/litewallet/src/Extensions/ApplicationController+Extension.swift @@ -0,0 +1,35 @@ +import Foundation +import StoreKit + +extension ApplicationController { + func setupDefaults() { + if UserDefaults.standard.object(forKey: shouldRequireLoginTimeoutKey) == nil { + UserDefaults.standard.set(60.0 * 3.0, forKey: shouldRequireLoginTimeoutKey) // Default 3 min timeout + } + if UserDefaults.standard.object(forKey: hasSeenAnnounceView) == nil { + UserDefaults.standard.set(false, forKey: hasSeenAnnounceView) // Hasnt seen the Announce View + } + } + + func countLaunches() { + if var launchNumber = UserDefaults.standard.object(forKey: numberOfLitewalletLaunches) as? Int { + launchNumber += 1 + UserDefaults.standard.set(NSNumber(value: launchNumber), forKey: numberOfLitewalletLaunches) + if launchNumber == 5 { + SKStoreReviewController.requestReview() + + SKStoreReviewController.requestReviewInCurrentScene() + + // iOSAppStoreURLFormat = @"itms-apps://itunes.apple.com/WebObjects/MZStore.woa/wa/viewContentsUserReviews?type=Purple+Software&id=%d"; + // [NSURL URLWithString:[NSString stringWithFormat:([[UIDevice currentDevice].systemVersion floatValue] >= 7.0f)? iOS7AppStoreURLFormat: iOSAppStoreURLFormat, YOUR_APP_STORE_ID]]; // Would contain the right link + + LWAnalytics.logEventWithParameters(itemName: ._20200125_DSRR) + } + } else { + UserDefaults.standard.set(NSNumber(value: 1), forKey: numberOfLitewalletLaunches) + } + } + + func willResignActive() + {} +} diff --git a/litewallet/src/Extensions/Async.swift b/litewallet/src/Extensions/Async.swift new file mode 100644 index 000000000..22dbf25c6 --- /dev/null +++ b/litewallet/src/Extensions/Async.swift @@ -0,0 +1,22 @@ +import Foundation + +enum Async { + static func parallel(callbacks: [(@escaping () -> Void) -> Void], completion: @escaping () -> Void) + { + let dispatchGroup = DispatchGroup() + callbacks.forEach { cb in + dispatchGroup.enter() + cb { + dispatchGroup.leave() + } + } + dispatchGroup.notify(queue: .main) { + completion() + } + } +} + +func delay(_ delay: Double, closure: @escaping () -> Void) { + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + + Double(Int64(delay * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC), execute: closure) +} diff --git a/litewallet/src/Extensions/CGContext+Additions.swift b/litewallet/src/Extensions/CGContext+Additions.swift new file mode 100644 index 000000000..29b720a3a --- /dev/null +++ b/litewallet/src/Extensions/CGContext+Additions.swift @@ -0,0 +1,11 @@ +import UIKit + +extension CGContext { + func addLineThrough(_ points: [(CGFloat, CGFloat)]) { + guard let first = points.first else { return } + move(to: CGPoint(x: first.0, y: first.1)) + points.dropFirst().forEach { + addLine(to: CGPoint(x: $0.0, y: $0.1)) + } + } +} diff --git a/litewallet/src/Extensions/CGRect+Additions.swift b/litewallet/src/Extensions/CGRect+Additions.swift new file mode 100644 index 000000000..91aafee73 --- /dev/null +++ b/litewallet/src/Extensions/CGRect+Additions.swift @@ -0,0 +1,14 @@ +import UIKit + +extension CGRect { + var center: CGPoint { + return CGPoint(x: midX, y: midY) + } + + func expandVertically(_ deltaY: CGFloat) -> CGRect { + var newFrame = self + newFrame.origin.y = newFrame.origin.y - deltaY + newFrame.size.height = newFrame.size.height + deltaY + return newFrame + } +} diff --git a/litewallet/src/Extensions/CustomTitleView.swift b/litewallet/src/Extensions/CustomTitleView.swift new file mode 100644 index 000000000..70674b64c --- /dev/null +++ b/litewallet/src/Extensions/CustomTitleView.swift @@ -0,0 +1,89 @@ +import UIKit + +protocol CustomTitleView { + var customTitle: String { get } + var titleLabel: UILabel { get } + var navigationItem: UINavigationItem { get } +} + +private struct AssociatedKeys { + static var label = "label" + static var yPosition = "yPosition" +} + +private let restingLabelPosition: CGFloat = 60.0 + +extension CustomTitleView { + var label: UILabel { + var textColor: UIColor + + if #available(iOS 11.0, *) { + textColor = UIColor(named: "labelTextColor")! + } else { + textColor = .darkText + } + + guard let label = objc_getAssociatedObject(self, &AssociatedKeys.label) as? UILabel + else { + let newLabel = UILabel(font: .customBold(size: 17.0), color: textColor) + objc_setAssociatedObject(self, &AssociatedKeys.label, newLabel, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return newLabel + } + return label + } + + var yPosition: NSLayoutConstraint? { + get { + guard let yPosition = objc_getAssociatedObject(self, &AssociatedKeys.yPosition) as? NSLayoutConstraint + else { + return nil + } + return yPosition + } + set { + guard let newValue = newValue else { return } + objc_setAssociatedObject(self, &AssociatedKeys.yPosition, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + func addCustomTitle() { + let titleView = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 40)) + + if #available(iOS 11.0, *) { + titleView.backgroundColor = UIColor(named: "lfBackgroundColor") + label.textColor = UIColor(named: "labelTextColor") + } else { + titleView.backgroundColor = .clear + label.textColor = .darkText + } + + titleView.clipsToBounds = true + label.text = customTitle + titleView.addSubview(label) + let newYPosition = label.centerYAnchor.constraint(equalTo: titleView.centerYAnchor) + newYPosition.constant = restingLabelPosition + objc_setAssociatedObject(self, &AssociatedKeys.yPosition, newYPosition, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + label.constrain([ + yPosition, + label.centerXAnchor.constraint(equalTo: titleView.centerXAnchor), + ]) + navigationItem.titleView = titleView + } + + func didScrollForCustomTitle(yOffset: CGFloat) { + let progress = min(yOffset / restingLabelPosition, 1.0) + titleLabel.alpha = 1.0 - progress + yPosition?.constant = restingLabelPosition - (restingLabelPosition * progress) + } + + func scrollViewWillEndDraggingForCustomTitle(yOffset: CGFloat) { + let progress = min(yOffset / restingLabelPosition, 1.0) + if progress > 0.2 { + UIView.animate(withDuration: 0.3, animations: { + self.yPosition?.constant = 0.0 + self.titleLabel.alpha = 0.0 + self.titleLabel.superview?.layoutIfNeeded() + }) + } + } +} diff --git a/litewallet/src/Extensions/Date+Additions.swift b/litewallet/src/Extensions/Date+Additions.swift new file mode 100644 index 000000000..dba297886 --- /dev/null +++ b/litewallet/src/Extensions/Date+Additions.swift @@ -0,0 +1,11 @@ +import Foundation + +extension Date { + func hasEqualYear(_ date: Date) -> Bool { + return Calendar.current.compare(self, to: date, toGranularity: .year) == .orderedSame + } + + func hasEqualMonth(_ date: Date) -> Bool { + return Calendar.current.compare(self, to: date, toGranularity: .month) == .orderedSame + } +} diff --git a/litewallet/src/Extensions/DispatchQueue+Additions.swift b/litewallet/src/Extensions/DispatchQueue+Additions.swift new file mode 100644 index 000000000..3d1e31efb --- /dev/null +++ b/litewallet/src/Extensions/DispatchQueue+Additions.swift @@ -0,0 +1,7 @@ +import Foundation + +extension DispatchQueue { + static var walletQueue: DispatchQueue = .init(label: C.walletQueue) + + static let walletConcurrentQueue: DispatchQueue = .init(label: C.walletQueue, attributes: .concurrent) +} diff --git a/litewallet/src/Extensions/LAContext+Extensions.swift b/litewallet/src/Extensions/LAContext+Extensions.swift new file mode 100644 index 000000000..7f4b69d11 --- /dev/null +++ b/litewallet/src/Extensions/LAContext+Extensions.swift @@ -0,0 +1,51 @@ +import Foundation +import LocalAuthentication + +extension LAContext { + static var canUseBiometrics: Bool { + return LAContext().canEvaluatePolicy(LAPolicy.deviceOwnerAuthenticationWithBiometrics, error: nil) + } + + static var isBiometricsAvailable: Bool { + var error: NSError? + if LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { + return true + } else { + if error?.code == LAError.biometryNotAvailable.rawValue { + return false + } else { + return true + } + } + } + + static var isPasscodeEnabled: Bool { + return LAContext().canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) + } + + static func biometricType() -> BiometricType { + let context = LAContext() + if #available(iOS 11, *) { + _ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) + switch context.biometryType { + case .none: + return .none + case .touchID: + return .touch + case .faceID: + return .face + case .opticID: + return .optical + } + } else { + return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) ? .touch : .none + } + } + + enum BiometricType { + case none + case touch + case face + case optical + } +} diff --git a/litewallet/src/Extensions/NumberFormatter+Additions.swift b/litewallet/src/Extensions/NumberFormatter+Additions.swift new file mode 100644 index 000000000..7875041e4 --- /dev/null +++ b/litewallet/src/Extensions/NumberFormatter+Additions.swift @@ -0,0 +1,28 @@ +import Foundation + +extension NumberFormatter { + static func formattedString(amount: Satoshis, rate: Rate?, minimumFractionDigits: Int?, maxDigits: Int) -> String + { + let displayAmount = Amount(amount: amount.rawValue, rate: rate ?? Rate.empty, maxDigits: maxDigits) + var formatter: NumberFormatter + var output = "" + if let rate = rate { + formatter = NumberFormatter() + if let minimumFractionDigits = minimumFractionDigits { + formatter.minimumFractionDigits = minimumFractionDigits + } + formatter.locale = rate.locale + formatter.numberStyle = .currency + let value = (Double(amount.rawValue) / Double(C.satoshis)) * rate.rate + output = formatter.string(from: value as NSNumber) ?? "error" + } else { + formatter = displayAmount.ltcFormat + if let minimumFractionDigits = minimumFractionDigits { + formatter.minimumFractionDigits = minimumFractionDigits + } + let bits = Bits(satoshis: amount) + output = formatter.string(from: bits.rawValue as NSNumber) ?? "error" + } + return output + } +} diff --git a/litewallet/src/Extensions/SafariServices+Extension.swift b/litewallet/src/Extensions/SafariServices+Extension.swift new file mode 100644 index 000000000..06edd45e3 --- /dev/null +++ b/litewallet/src/Extensions/SafariServices+Extension.swift @@ -0,0 +1,98 @@ +import Foundation +import SafariServices +import SwiftUI +import UIKit +import WebKit + +// inspired https://www.swiftyplace.com/blog/loading-a-web-view-in-swiftui-with-wkwebview + +struct WebView: UIViewRepresentable { + let url: URL + @Binding + var scrollToSignup: Bool + + @State + private + var didStartEditing: Bool = false + + func makeUIView(context _: Context) -> WKWebView { + let webview = SignupWebView(frame: CGRectZero, didStartEditing: $didStartEditing) + let request = URLRequest(url: url) + webview.load(request) + return webview + } + + func updateUIView(_ webview: WKWebView, context _: Context) { + print("::: webview \(webview.frame.size)") + + webview.endEditing(true) + + if scrollToSignup { + let point = CGPoint(x: 0, y: webview.scrollView.contentSize.height - webview.frame.size.height / 2) + print("::: point \(point)") + + webview.scrollView.setContentOffset(point, animated: true) + DispatchQueue.main.async { + self.scrollToSignup = false + } + } + } +} + +// https://stackoverflow.com/questions/44684714/show-keyboard-on-button-click-by-calling-wkwebview-input-field +class SignupWebView: WKWebView, WKNavigationDelegate { + @Binding + var didStartEditing: Bool + + init(frame: CGRect, didStartEditing: Binding) { + _didStartEditing = didStartEditing + + let configuration = WKWebViewConfiguration() + super.init(frame: frame, configuration: configuration) + navigationDelegate = self + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + return scrollView.contentSize + } + + func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { + var scriptContent = "var meta = document.createElement('meta');" + scriptContent += "meta.name='viewport';" + scriptContent += "meta.content='width=device-width';" + scriptContent += "document.getElementsByTagName('head')[0].appendChild(meta);" + scriptContent += "document.body.scrollHeight;" + + webView.evaluateJavaScript(scriptContent, completionHandler: { height, error in + + print(height) + print(error) + }) + + webView.evaluateJavaScript("document.body.innerHTML", completionHandler: { (value: Any!, error: Error!) in + if error != nil { + // Error logic + return + } + // webView.invalidateIntrinsicContentSize() + + // let js = "document.getElementById(\"MY_TEXTFIELD_ID\").focus();" + // webView.evaluateJavaScript(js) + + // webview.canBecomeFocused = true + + // document.getElementById('myID').focus(); + + // webview.scrollView.setZoomScale(0.3, animated: true) + + let result = value as? String + + print(value) + }) + } +} diff --git a/litewallet/src/Extensions/String+Additions.swift b/litewallet/src/Extensions/String+Additions.swift new file mode 100644 index 000000000..3808b4e38 --- /dev/null +++ b/litewallet/src/Extensions/String+Additions.swift @@ -0,0 +1,149 @@ +import BRCore +import FirebaseAnalytics +import Foundation +import UIKit + +extension String { + var isValidPrivateKey: Bool { + return BRPrivKeyIsValid(self) != 0 + } + + var isValidBip38Key: Bool { + return BRBIP38KeyIsValid(self) != 0 + } + + var isValidAddress: Bool { + guard lengthOfBytes(using: .utf8) > 0 else { return false } + return BRAddressIsValid(self) != 0 + } + + var sanitized: String { + return applyingTransform(.toUnicodeName, reverse: false) ?? "" + } + + func ltrim(_ chars: Set) -> String { + if let index = index(where: { !chars.contains($0) }) { + return String(self[index ..< endIndex]) + } else { + return "" + } + } + + func rtrim(_ chars: Set) -> String { + if let index = reversed().index(where: { !chars.contains($0) }) { + return String(self[startIndex ... self.index(before: index.base)]) + } else { + return "" + } + } + + func nsRange(from range: Range) -> NSRange { + let location = utf16.distance(from: utf16.startIndex, to: range.lowerBound) + let length = utf16.distance(from: range.lowerBound, to: range.upperBound) + return NSRange(location: location, length: length) + } +} + +private let startTag = "" +private let endTag = "" + +// Convert string with tags to attributed string +extension String { + var tagsRemoved: String { + return replacingOccurrences(of: startTag, with: "").replacingOccurrences(of: endTag, with: "") + } + + var attributedStringForTags: NSAttributedString { + let output = NSMutableAttributedString() + let scanner = Scanner(string: self) + let endCount = tagsRemoved.utf8.count + var i = 0 + while output.string.utf8.count < endCount || i < 50 { + var regular: NSString? + var bold: NSString? + scanner.scanUpTo(startTag, into: ®ular) + scanner.scanUpTo(endTag, into: &bold) + if let regular = regular { + output.append(NSAttributedString(string: (regular as String).tagsRemoved, attributes: UIFont.regularAttributes)) + } + if let bold = bold { + output.append(NSAttributedString(string: (bold as String).tagsRemoved, attributes: UIFont.boldAttributes)) + } + i += 1 + } + return output + } +} + +// MARK: - Hex String conversions + +extension String { + var hexToData: Data? { + let scalars = unicodeScalars + var bytes = [UInt8](repeating: 0, count: (scalars.count + 1) >> 1) + for (index, scalar) in scalars.enumerated() { + guard var nibble = scalar.nibble else { return nil } + if index & 1 == 0 { + nibble <<= 4 + } + bytes[index >> 1] |= nibble + } + return Data(bytes: bytes) + } + + static func localizedString(for key: String, + locale: Locale = .current) -> String + { + let language = locale.languageCode + let path = Bundle.main.path(forResource: language, ofType: "lproj")! + let bundle = Bundle(path: path)! + let localizedString = NSLocalizedString(key, bundle: bundle, comment: "") + + return localizedString + } +} + +extension UnicodeScalar { + var nibble: UInt8? { + if value >= 48, value <= 57 { + return UInt8(value - 48) + } else if value >= 65, value <= 70 { + return UInt8(value - 55) + } else if value >= 97, value <= 102 { + return UInt8(value - 87) + } + return nil + } +} + +extension String { + func capitalizingFirstLetter() -> String { + return prefix(1).uppercased() + lowercased().dropFirst() + } + + mutating func capitalizeFirstLetter() { + self = capitalizingFirstLetter() + } + + func replacingZeroFeeWithTenCents() -> String { + guard count > 3 + else { + LWAnalytics.logEventWithParameters(itemName: ._20200112_ERR, properties: ["ERROR": "STRING_ISSUE"]) + return self + } + + let range = index(endIndex, offsetBy: -3) ..< endIndex + return replacingOccurrences(of: ".00", with: ".10", options: .literal, range: range) + } + + func combinedFeeReplacingZeroFeeWithTenCents() -> String { + guard count > 4 + else { + LWAnalytics.logEventWithParameters(itemName: ._20200112_ERR, properties: ["ERROR": "STRING_ISSUE"]) + return self + } + + let range = index(endIndex, offsetBy: -4) ..< endIndex + return replacingOccurrences(of: ".00)", with: ".10)", options: .literal, range: range) + } +} diff --git a/litewallet/src/Extensions/UIBarButtonItem+Additions.swift b/litewallet/src/Extensions/UIBarButtonItem+Additions.swift new file mode 100644 index 000000000..84cb94262 --- /dev/null +++ b/litewallet/src/Extensions/UIBarButtonItem+Additions.swift @@ -0,0 +1,9 @@ +import UIKit + +extension UIBarButtonItem { + static var negativePadding: UIBarButtonItem { + let padding = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil) + padding.width = -16.0 + return padding + } +} diff --git a/litewallet/src/Extensions/UIButton+BRWAdditions.swift b/litewallet/src/Extensions/UIButton+BRWAdditions.swift new file mode 100644 index 000000000..ff67559f0 --- /dev/null +++ b/litewallet/src/Extensions/UIButton+BRWAdditions.swift @@ -0,0 +1,68 @@ +import UIKit + +extension UIButton { + static func vertical(title: String, image: UIImage) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.setImage(image, for: .normal) + button.titleLabel?.font = UIFont.customMedium(size: 11.0) + if let imageSize = button.imageView?.image?.size, + let font = button.titleLabel?.font + { + let spacing: CGFloat = C.padding[1] / 2.0 + let titleSize = NSString(string: title).size(withAttributes: [NSAttributedString.Key.font: font]) + + // These edge insets place the image vertically above the title label + button.titleEdgeInsets = UIEdgeInsets(top: 0.0, left: -imageSize.width, bottom: -(imageSize.height + spacing), right: 0.0) + button.imageEdgeInsets = UIEdgeInsets(top: -(titleSize.height + spacing), left: 0.0, bottom: 0.0, right: -titleSize.width) + } + return button + } + + static var close: UIButton { + let accessibilityLabel = E.isScreenshots ? "Close" : S.AccessibilityLabels.close.localize() + return UIButton.icon(image: #imageLiteral(resourceName: "Close"), accessibilityLabel: accessibilityLabel) + } + + static func buildFaqButton(store: Store, articleId: String) -> UIButton { + let button = UIButton.icon(image: #imageLiteral(resourceName: "Faq"), accessibilityLabel: S.AccessibilityLabels.faq.localize()) + button.tap = { + store.trigger(name: .presentFaq(articleId)) + } + return button + } + + static func icon(image: UIImage, accessibilityLabel: String) -> UIButton { + let button = UIButton(type: .system) + button.frame = CGRect(x: 0, y: 0, width: 44, height: 44) + button.setImage(image, for: .normal) + + if image == #imageLiteral(resourceName: "Close") { + button.imageEdgeInsets = UIEdgeInsets(top: 14.0, left: 14.0, bottom: 14.0, right: 14.0) + } else { + button.imageEdgeInsets = UIEdgeInsets(top: 12.0, left: 12.0, bottom: 12.0, right: 12.0) + } + + button.tintColor = .darkText + button.accessibilityLabel = accessibilityLabel + return button + } + + func tempDisable() { + isEnabled = false + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.isEnabled = true + } + } + + static func stylizeLitewalletBlueButton(title: String, frame: CGRect) -> UIButton { + let button = UIButton() + button.frame = frame + button.setTitle(title, for: .normal) + button.layer.cornerRadius = 4.0 + button.titleLabel?.textColor = .white + button.titleLabel?.font = UIFont.barlowMedium(size: 18) + button.clipsToBounds = true + return button + } +} diff --git a/litewallet/src/Extensions/UIColor+Extension.swift b/litewallet/src/Extensions/UIColor+Extension.swift new file mode 100644 index 000000000..0668402cd --- /dev/null +++ b/litewallet/src/Extensions/UIColor+Extension.swift @@ -0,0 +1,176 @@ +import SwiftUI +import UIKit + +extension UIColor { + // TODO: New Color Scheme + // #A6A9AA, Silver C, UIColor(red: 166.0/255.0, green: 169.0/255.0, blue: 170.0/255.0, alpha: 1.0) + // #4D4D4E, Cool Gray 11 C + // #FFFFFF, White + // #345D9D, Blue, 7684 C, UIColor(red: 52.0/255.0, green: 52.0/255.0, blue: 157.0/255.0, alpha: 1.0) + + static var litecoinWhite: UIColor { + return .white + } + + static var litecoinGray: UIColor { // F1F1F1 + return #colorLiteral(red: 0.9450980392, green: 0.9450980392, blue: 0.9450980392, alpha: 1) + } + + static var litewalletLightGray: UIColor { // F8F8F8 + return #colorLiteral(red: 0.9725490196, green: 0.9725490196, blue: 0.9725490196, alpha: 1) + } + + static var litecoinSilver: UIColor { // A6A9AA + return #colorLiteral(red: 0.6509803922, green: 0.662745098, blue: 0.6666666667, alpha: 1) + } + + static var litecoinDarkSilver: UIColor { // 4D4D4E + return #colorLiteral(red: 0.3019607843, green: 0.3019607843, blue: 0.3058823529, alpha: 1) + } + + static var liteWalletBlue: UIColor { // 345D9D + return #colorLiteral(red: 0.2039215686, green: 0.3647058824, blue: 0.6156862745, alpha: 1) + } + + static var liteWalletDarkBlue: UIColor { // 0C3475 + return #colorLiteral(red: 0.04705882353, green: 0.2039215686, blue: 0.4588235294, alpha: 1) + } + + static var litewalletOrange: UIColor { // FE5F55 + return #colorLiteral(red: 0.9960784314, green: 0.3725490196, blue: 0.3333333333, alpha: 1) + } + + static var litecoinGreen: UIColor { // 179E27 + return #colorLiteral(red: 0.09019607843, green: 0.6196078431, blue: 0.1529411765, alpha: 1) + } + + static var litewalletLime: UIColor { // D9E76C + return #colorLiteral(red: 0.8509803922, green: 0.9058823529, blue: 0.4235294118, alpha: 1) + } + + // MARK: Buttons + + static var primaryButton: UIColor { + return #colorLiteral(red: 0.2980392157, green: 0.5960784314, blue: 0.9882352941, alpha: 1) // UIColor(red: 76.0/255.0, green: 152.0/255.0, blue: 252.0/255.0, alpha: 1.0) + } + + static var primaryText: UIColor { + return .white + } + + static var secondaryButton: UIColor { + return #colorLiteral(red: 0.9607843137, green: 0.968627451, blue: 0.9803921569, alpha: 1) // UIColor(red: 245.0/255.0, green: 247.0/255.0, blue: 250.0/255.0, alpha: 1.0) + } + + static var secondaryBorder: UIColor { + return #colorLiteral(red: 0.8352941176, green: 0.8549019608, blue: 0.8784313725, alpha: 1) // UIColor(red: 213.0/255.0, green: 218.0/255.0, blue: 224.0/255.0, alpha: 1.0) + } + + static var darkText: UIColor { + return #colorLiteral(red: 0.137254902, green: 0.1450980392, blue: 0.1490196078, alpha: 1) // UIColor(red: 35.0/255.0, green: 37.0/255.0, blue: 38.0/255.0, alpha: 1.0) + } + + static var darkLine: UIColor { + return #colorLiteral(red: 0.1411764706, green: 0.137254902, blue: 0.1490196078, alpha: 1) // UIColor(red: 36.0/255.0, green: 35.0/255.0, blue: 38.0/255.0, alpha: 1.0) + } + + static var secondaryShadow: UIColor { + return UIColor(red: 213.0 / 255.0, green: 218.0 / 255.0, blue: 224.0 / 255.0, alpha: 1.0) + } + + static var offWhite: UIColor { + return UIColor(white: 247.0 / 255.0, alpha: 1.0) + } + + static var borderGray: UIColor { + return UIColor(white: 221.0 / 255.0, alpha: 1.0) + } + + static var separatorGray: UIColor { + return UIColor(white: 221.0 / 255.0, alpha: 1.0) + } + + static var grayText: UIColor { + return #colorLiteral(red: 0.5333333333, green: 0.5333333333, blue: 0.5333333333, alpha: 1) // UIColor(white: 136.0/255.0, alpha: 1.0) + } + + static var grayTextTint: UIColor { + return UIColor(red: 163.0 / 255.0, green: 168.0 / 255.0, blue: 173.0 / 255.0, alpha: 1.0) + } + + static var secondaryGrayText: UIColor { + return UIColor(red: 101.0 / 255.0, green: 105.0 / 255.0, blue: 110.0 / 255.0, alpha: 1.0) + } + + static var grayBackgroundTint: UIColor { + return UIColor(red: 250.0 / 255.0, green: 251.0 / 255.0, blue: 252.0 / 255.0, alpha: 1.0) + } + + static var cameraGuidePositive: UIColor { + return UIColor(red: 72.0 / 255.0, green: 240.0 / 255.0, blue: 184.0 / 255.0, alpha: 1.0) + } + + static var purple: UIColor { + return UIColor(red: 209.0 / 255.0, green: 125.0 / 255.0, blue: 245.0 / 255.0, alpha: 1.0) + } + + static var darkPurple: UIColor { + return UIColor(red: 127.0 / 255.0, green: 83.0 / 255.0, blue: 230.0 / 255.0, alpha: 1.0) + } + + static var pink: UIColor { + return UIColor(red: 252.0 / 255.0, green: 83.0 / 255.0, blue: 148.0 / 255.0, alpha: 1.0) + } + + static var blue: UIColor { + return UIColor(red: 76.0 / 255.0, green: 152.0 / 255.0, blue: 252.0 / 255.0, alpha: 1.0) + } + + // MARK: Gradient + + static var gradientStart: UIColor { + return UIColor(red: 131.0 / 255.0, green: 175.0 / 255.0, blue: 224.0 / 255.0, alpha: 1.0) + } + + static var gradientEnd: UIColor { + return UIColor(red: 118.0 / 255.0, green: 126.0 / 255.0, blue: 227.0 / 255.0, alpha: 1.0) + } + + static var whiteTint: UIColor { + return UIColor(red: 245.0 / 255.0, green: 247.0 / 255.0, blue: 250.0 / 255.0, alpha: 1.0) + } + + static var transparentWhite: UIColor { + return UIColor(white: 1.0, alpha: 0.3) + } + + static var transparentBlack: UIColor { + return UIColor(white: 0.0, alpha: 0.3) + } + + static var blueGradientStart: UIColor { + return UIColor(red: 99.0 / 255.0, green: 188.0 / 255.0, blue: 255.0 / 255.0, alpha: 1.0) + } + + static var blueGradientEnd: UIColor { + return UIColor(red: 56.0 / 255.0, green: 141.0 / 255.0, blue: 252.0 / 255.0, alpha: 1.0) + } +} + +extension Color { + static var litewalletBlue: Color { // 345D9D + return Color(#colorLiteral(red: 0.2039215686, green: 0.3647058824, blue: 0.6156862745, alpha: 1)) + } + + static var litewalletDarkBlue: Color { // 0C3475 + return Color(#colorLiteral(red: 0.04705882353, green: 0.2039215686, blue: 0.4588235294, alpha: 1)) + } + + static var litewalletOrange: Color { // FE5F55 + return Color(#colorLiteral(red: 0.9960784314, green: 0.3725490196, blue: 0.3333333333, alpha: 1)) + } + + static var litewalletGreen: Color { // 179E27 + return Color(#colorLiteral(red: 0.09019607843, green: 0.6196078431, blue: 0.1529411765, alpha: 1)) + } +} diff --git a/litewallet/src/Extensions/UIControl+Callback.swift b/litewallet/src/Extensions/UIControl+Callback.swift new file mode 100644 index 000000000..a20960367 --- /dev/null +++ b/litewallet/src/Extensions/UIControl+Callback.swift @@ -0,0 +1,69 @@ +import UIKit + +private class CallbackWrapper: NSObject, NSCopying { + init(_ callback: @escaping () -> Void) { + self.callback = callback + } + + let callback: () -> Void + + func copy(with _: NSZone? = nil) -> Any { + return CallbackWrapper(callback) + } +} + +private struct AssociatedKeys { + static var didTapCallback = "didTapCallback" + static var valueChangedCallback = "valueChangedCallback" + static var valueEditingChangedCallback = "valueEditingChangedCallback" +} + +extension UIControl { + var tap: (() -> Void)? { + get { + guard let callbackWrapper = objc_getAssociatedObject(self, &AssociatedKeys.didTapCallback) as? CallbackWrapper else { return nil } + return callbackWrapper.callback + } + set { + guard let newValue = newValue else { return } + addTarget(self, action: #selector(didTap), for: .touchUpInside) + objc_setAssociatedObject(self, &AssociatedKeys.didTapCallback, CallbackWrapper(newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + @objc private func didTap() { + tap?() + } + + var valueChanged: (() -> Void)? { + get { + guard let callbackWrapper = objc_getAssociatedObject(self, &AssociatedKeys.valueChangedCallback) as? CallbackWrapper else { return nil } + return callbackWrapper.callback + } + set { + guard let newValue = newValue else { return } + addTarget(self, action: #selector(valueDidChange), for: .valueChanged) + objc_setAssociatedObject(self, &AssociatedKeys.valueChangedCallback, CallbackWrapper(newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + var editingChanged: (() -> Void)? { + get { + guard let callbackWrapper = objc_getAssociatedObject(self, &AssociatedKeys.valueEditingChangedCallback) as? CallbackWrapper else { return nil } + return callbackWrapper.callback + } + set { + guard let newValue = newValue else { return } + addTarget(self, action: #selector(editingChange), for: .editingChanged) + objc_setAssociatedObject(self, &AssociatedKeys.valueEditingChangedCallback, CallbackWrapper(newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + @objc private func valueDidChange() { + valueChanged?() + } + + @objc private func editingChange() { + editingChanged?() + } +} diff --git a/litewallet/src/Extensions/UIFont+BRWAdditions.swift b/litewallet/src/Extensions/UIFont+BRWAdditions.swift new file mode 100644 index 000000000..255c16e55 --- /dev/null +++ b/litewallet/src/Extensions/UIFont+BRWAdditions.swift @@ -0,0 +1,84 @@ +import SwiftUI +import UIKit + +extension UIFont { + static var header: UIFont { + return UIFont(name: "BarlowSemiCondensed-Bold", size: 17.0) ?? UIFont.preferredFont(forTextStyle: .headline) + } + + static func customBold(size: CGFloat) -> UIFont { + return UIFont(name: "BarlowSemiCondensed-Bold", size: size) ?? UIFont.preferredFont(forTextStyle: .headline) + } + + static func customBody(size: CGFloat) -> UIFont { + return UIFont(name: "BarlowSemiCondensed-Regular", size: size) ?? UIFont.preferredFont(forTextStyle: .subheadline) + } + + static func customMedium(size: CGFloat) -> UIFont { + return UIFont(name: "BarlowSemiCondensed-Medium", size: size) ?? UIFont.preferredFont(forTextStyle: .body) + } + + static func barlowBold(size: CGFloat) -> UIFont { + return UIFont(name: "BarlowSemiCondensed-Bold", size: size) ?? UIFont.preferredFont(forTextStyle: .body) + } + + static func barlowSemiBold(size: CGFloat) -> UIFont { + return UIFont(name: "BarlowSemiCondensed-SemiBold", size: size) ?? UIFont.preferredFont(forTextStyle: .body) + } + + static func barlowItalic(size: CGFloat) -> UIFont { + return UIFont(name: "BarlowSemiCondensed-Italic", size: size) ?? UIFont.preferredFont(forTextStyle: .body) + } + + static func barlowMedium(size: CGFloat) -> UIFont { + return UIFont(name: "BarlowSemiCondensed-Medium", size: size) ?? UIFont.preferredFont(forTextStyle: .body) + } + + static func barlowRegular(size: CGFloat) -> UIFont { + return UIFont(name: "BarlowSemiCondensed-Regular", size: size) ?? UIFont.preferredFont(forTextStyle: .body) + } + + static func barlowLight(size: CGFloat) -> UIFont { + return UIFont(name: "BarlowSemiCondensed-Light", size: size) ?? UIFont.preferredFont(forTextStyle: .body) + } + + static var regularAttributes: [NSAttributedString.Key: Any] { + return [ + .font: UIFont.customBody(size: 14.0), + .foregroundColor: UIColor.darkText, + ] + } + + static var boldAttributes: [NSAttributedString.Key: Any] { + return [ + .font: UIFont.customBold(size: 14.0), + .foregroundColor: UIColor.darkText, + ] + } +} + +extension Font { + static func barlowSemiBold(size: CGFloat) -> Font { + return Font.custom("BarlowSemiCondensed-SemiBold", size: size) + } + + static func barlowBold(size: CGFloat) -> Font { + return Font.custom("BarlowSemiCondensed-Bold", size: size) + } + + static func barlowItalic(size: CGFloat) -> Font { + return Font.custom("BarlowSemiCondensed-Italic", size: size) + } + + static func barlowMedium(size: CGFloat) -> Font { + return Font.custom("BarlowSemiCondensed-Medium", size: size) + } + + static func barlowRegular(size: CGFloat) -> Font { + return Font.custom("BarlowSemiCondensed-Regular", size: size) + } + + static func barlowLight(size: CGFloat) -> Font { + return Font.custom("BarlowSemiCondensed-Light", size: size) + } +} diff --git a/litewallet/src/Extensions/UIImage+Utils.swift b/litewallet/src/Extensions/UIImage+Utils.swift new file mode 100644 index 000000000..2474a7550 --- /dev/null +++ b/litewallet/src/Extensions/UIImage+Utils.swift @@ -0,0 +1,60 @@ +import CoreGraphics +import UIKit + +private let inputImageKey = "inputImage" + +extension UIImage { + static func qrCode(data: Data, color: CIColor) -> UIImage? { + let qrFilter = CIFilter(name: "CIQRCodeGenerator") + let maskFilter = CIFilter(name: "CIMaskToAlpha") + let invertFilter = CIFilter(name: "CIColorInvert") + let colorFilter = CIFilter(name: "CIFalseColor") + var filter = colorFilter + + qrFilter?.setValue(data, forKey: "inputMessage") + qrFilter?.setValue("L", forKey: "inputCorrectionLevel") + + if Double(color.alpha) > .ulpOfOne { + invertFilter?.setValue(qrFilter?.outputImage, forKey: inputImageKey) + maskFilter?.setValue(invertFilter?.outputImage, forKey: inputImageKey) + invertFilter?.setValue(maskFilter?.outputImage, forKey: inputImageKey) + colorFilter?.setValue(invertFilter?.outputImage, forKey: inputImageKey) + colorFilter?.setValue(color, forKey: "inputColor0") + } else { + maskFilter?.setValue(qrFilter?.outputImage, forKey: inputImageKey) + filter = maskFilter + } + + // force software rendering for security (GPU rendering causes image artifacts on iOS 7 and is generally crashy) + let context = CIContext(options: [.useSoftwareRenderer: true]) + objc_sync_enter(context) + defer { objc_sync_exit(context) } + guard let outputImage = filter?.outputImage else { assertionFailure("No qr output image"); return nil } + guard let cgImage = context.createCGImage(outputImage, from: outputImage.extent) else { assertionFailure("Could not create image."); return nil } + return UIImage(cgImage: cgImage) + } + + func resize(_ size: CGSize) -> UIImage? { + UIGraphicsBeginImageContext(size) + defer { UIGraphicsEndImageContext() } + guard let context = UIGraphicsGetCurrentContext() else { assertionFailure("Could not create image context"); return nil } + guard let cgImage = cgImage else { assertionFailure("No cgImage property"); return nil } + + context.interpolationQuality = .none + context.rotate(by: π) // flip + context.scaleBy(x: -1.0, y: 1.0) // mirror + context.draw(cgImage, in: context.boundingBoxOfClipPath) + return UIGraphicsGetImageFromCurrentImageContext() + } + + static func imageForColor(_ color: UIColor) -> UIImage { + let rect = CGRect(x: 0, y: 0, width: 1.0, height: 1.0) + UIGraphicsBeginImageContext(rect.size) + let context = UIGraphicsGetCurrentContext() + context?.setFillColor(color.cgColor) + context?.fill(rect) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image ?? UIImage() + } +} diff --git a/litewallet/src/Extensions/UILabel+BRWAdditions.swift b/litewallet/src/Extensions/UILabel+BRWAdditions.swift new file mode 100644 index 000000000..1dfd36f18 --- /dev/null +++ b/litewallet/src/Extensions/UILabel+BRWAdditions.swift @@ -0,0 +1,42 @@ +import UIKit + +extension UILabel { + static func wrapping(font: UIFont, color: UIColor) -> UILabel { + let label = UILabel() + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.font = font + label.textColor = color + return label + } + + static func wrapping(font: UIFont) -> UILabel { + let label = UILabel() + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.font = font + return label + } + + convenience init(font: UIFont) { + self.init() + self.font = font + } + + convenience init(font: UIFont, color: UIColor) { + self.init() + self.font = font + textColor = color + } + + func pushNewText(_ newText: String) { + let animation = CATransition() + animation.timingFunction = CAMediaTimingFunction(name: + CAMediaTimingFunctionName.easeInEaseOut) + animation.type = CATransitionType.push + animation.subtype = CATransitionSubtype.fromTop + animation.duration = C.animationDuration + layer.add(animation, forKey: "kCATransitionPush") + text = newText + } +} diff --git a/litewallet/src/Extensions/UINavigationController+Extension.swift b/litewallet/src/Extensions/UINavigationController+Extension.swift new file mode 100644 index 000000000..1c9c05d6d --- /dev/null +++ b/litewallet/src/Extensions/UINavigationController+Extension.swift @@ -0,0 +1,57 @@ +import SwiftUI +import UIKit + +extension UINavigationController { + override open func viewDidLoad() { + super.viewDidLoad() + } + + func setDefaultStyle() { + setClearNavbar() + setBlackBackArrow() + } + + func setWhiteStyle() { + navigationBar.tintColor = .white + navigationBar.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: UIColor.white, + NSAttributedString.Key.font: UIFont.customBold(size: 17.0), + ] + setTintableBackArrow() + } + + func setClearNavbar() { + navigationBar.setBackgroundImage(UIImage(), for: .default) + navigationBar.shadowImage = UIImage() + navigationBar.isTranslucent = true + } + + func setNormalNavbar() { + navigationBar.setBackgroundImage(nil, for: .default) + navigationBar.shadowImage = nil + } + + func setBlackBackArrow() { + let image = #imageLiteral(resourceName: "Back") + let renderedImage = image.withRenderingMode(.alwaysOriginal) + navigationBar.backIndicatorImage = renderedImage + navigationBar.backIndicatorTransitionMaskImage = renderedImage + } + + func setTintableBackArrow() { + navigationBar.backIndicatorImage = #imageLiteral(resourceName: "Back") + navigationBar.backIndicatorTransitionMaskImage = #imageLiteral(resourceName: "Back") + } +} + +extension UINavigationBarAppearance { + func setColor(title: UIColor? = nil, background: UIColor? = nil) { + configureWithTransparentBackground() + if let titleColor = title { + titleTextAttributes = [.foregroundColor: titleColor] + } + backgroundColor = background + UINavigationBar.appearance().scrollEdgeAppearance = self + UINavigationBar.appearance().standardAppearance = self + } +} diff --git a/litewallet/src/Extensions/UIScreen+Additions.swift b/litewallet/src/Extensions/UIScreen+Additions.swift new file mode 100644 index 000000000..f47428c7e --- /dev/null +++ b/litewallet/src/Extensions/UIScreen+Additions.swift @@ -0,0 +1,7 @@ +import UIKit + +extension UIScreen { + var safeWidth: CGFloat { + return min(bounds.width, bounds.height) + } +} diff --git a/litewallet/src/Extensions/UISlider+Gradient.swift b/litewallet/src/Extensions/UISlider+Gradient.swift new file mode 100644 index 000000000..b60980b89 --- /dev/null +++ b/litewallet/src/Extensions/UISlider+Gradient.swift @@ -0,0 +1,24 @@ +import UIKit + +extension UISlider { + func addGradientTrack() { + superview?.layoutIfNeeded() + setMaximumTrackImage(imageForColors(colors: [UIColor.grayTextTint.cgColor, UIColor.grayTextTint.cgColor], offset: 4.0), for: .normal) + setMinimumTrackImage(imageForColors(colors: [UIColor.gradientStart.cgColor, UIColor.gradientEnd.cgColor]), for: .normal) + } + + private func imageForColors(colors: [CGColor], offset: CGFloat = 0.0) -> UIImage? { + let layer = CAGradientLayer() + layer.cornerRadius = bounds.height / 2.0 + layer.frame = CGRect(x: bounds.minX, y: bounds.minY, width: bounds.width - offset, height: bounds.height) + layer.colors = colors + layer.endPoint = CGPoint(x: 1.0, y: 1.0) + layer.startPoint = CGPoint(x: 0.0, y: 1.0) + + UIGraphicsBeginImageContextWithOptions(layer.frame.size, false, 0.0) + layer.render(in: UIGraphicsGetCurrentContext()!) + let layerImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return layerImage?.resizableImage(withCapInsets: .zero) + } +} diff --git a/litewallet/src/Extensions/UITableView+Additions.swift b/litewallet/src/Extensions/UITableView+Additions.swift new file mode 100644 index 000000000..9567cba4f --- /dev/null +++ b/litewallet/src/Extensions/UITableView+Additions.swift @@ -0,0 +1,9 @@ +import UIKit + +extension UIScrollView { + func verticallyOffsetContent(_ deltaY: CGFloat) { + contentOffset = CGPoint(x: contentOffset.x, y: contentOffset.y - deltaY) + contentInset = UIEdgeInsets(top: contentInset.top + deltaY, left: contentInset.left, bottom: contentInset.bottom, right: contentInset.right) + scrollIndicatorInsets = contentInset + } +} diff --git a/litewallet/src/Extensions/UIView+AnimationAdditions.swift b/litewallet/src/Extensions/UIView+AnimationAdditions.swift new file mode 100644 index 000000000..f716102c7 --- /dev/null +++ b/litewallet/src/Extensions/UIView+AnimationAdditions.swift @@ -0,0 +1,21 @@ +import UIKit + +extension UIView { + static func spring(_ duration: TimeInterval, delay: TimeInterval, animations: @escaping () -> Void, completion: @escaping (Bool) -> Void) + { + if #available(iOS 10.0, *) { + UIViewPropertyAnimator.springAnimation(duration, delay: delay, animations: animations, completion: { _ in completion(true) }) + } else { + UIView.animate(withDuration: duration, delay: delay, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.0, options: [], animations: animations, completion: completion) + } + } + + static func spring(_ duration: TimeInterval, animations: @escaping () -> Void, completion: @escaping (Bool) -> Void) + { + if #available(iOS 10.0, *) { + UIViewPropertyAnimator.springAnimation(duration, animations: animations, completion: { _ in completion(true) }) + } else { + UIView.animate(withDuration: duration, delay: 0.0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.0, options: [], animations: animations, completion: completion) + } + } +} diff --git a/litewallet/src/Extensions/UIView+BRWAdditions.swift b/litewallet/src/Extensions/UIView+BRWAdditions.swift new file mode 100644 index 000000000..d67869d60 --- /dev/null +++ b/litewallet/src/Extensions/UIView+BRWAdditions.swift @@ -0,0 +1,205 @@ +import UIKit + +enum Dimension { + case width + case height + + var layoutAttribute: NSLayoutConstraint.Attribute { + switch self { + case .width: + return .width + case .height: + return .height + } + } +} + +extension UIView { + var firstResponder: UIView? { + guard !isFirstResponder else { return self } + for subview in subviews { + if subview.isFirstResponder { + return subview + } + } + return nil + } + + func constrain(toSuperviewEdges: UIEdgeInsets?) { + guard let view = superview else { assertionFailure("Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + NSLayoutConstraint(item: self, attribute: .leading, relatedBy: .equal, toItem: view, attribute: .leading, multiplier: 1.0, constant: toSuperviewEdges?.left ?? 0.0), + NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: view, attribute: .top, multiplier: 1.0, constant: toSuperviewEdges?.top ?? 0.0), + NSLayoutConstraint(item: self, attribute: .trailing, relatedBy: .equal, toItem: view, attribute: .trailing, multiplier: 1.0, constant: toSuperviewEdges?.right ?? 0.0), + NSLayoutConstraint(item: self, attribute: .bottom, relatedBy: .equal, toItem: view, attribute: .bottom, multiplier: 1.0, constant: toSuperviewEdges?.bottom ?? 0.0), + ]) + } + + func constrain(_ constraints: [NSLayoutConstraint?]) { + guard superview != nil else { assertionFailure("Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate(constraints.compactMap { $0 }) + } + + func constraint(_ attribute: NSLayoutConstraint.Attribute, toView: UIView, constant: CGFloat?) -> NSLayoutConstraint? + { + guard superview != nil else { assertionFailure("Superview cannot be nil when adding contraints"); return nil } + translatesAutoresizingMaskIntoConstraints = false + return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: toView, attribute: attribute, multiplier: 1.0, constant: constant ?? 0.0) + } + + func constraint(_ attribute: NSLayoutConstraint.Attribute, toView: UIView) -> NSLayoutConstraint? { + guard superview != nil else { assertionFailure("Superview cannot be nil when adding contraints"); return nil } + translatesAutoresizingMaskIntoConstraints = false + return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: toView, attribute: attribute, multiplier: 1.0, constant: 0.0) + } + + func constraint(_ dimension: Dimension, constant: CGFloat) -> NSLayoutConstraint? { + guard superview != nil else { assertionFailure("Superview cannot be nil when adding contraints"); return nil } + translatesAutoresizingMaskIntoConstraints = false + return NSLayoutConstraint(item: self, attribute: dimension.layoutAttribute, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: constant) + } + + func constraint(toBottom: UIView, constant: CGFloat) -> NSLayoutConstraint? { + guard superview != nil else { assertionFailure("Superview cannot be nil when adding contraints"); return nil } + translatesAutoresizingMaskIntoConstraints = false + return NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: toBottom, attribute: .bottom, multiplier: 1.0, constant: constant) + } + + func pinToBottom(to: UIView, height: CGFloat) { + guard superview != nil else { assertionFailure("Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.width, toView: to), + constraint(toBottom: to, constant: 0.0), + constraint(.height, constant: height), + ]) + } + + func constraint(toTop: UIView, constant: CGFloat) -> NSLayoutConstraint? { + guard superview != nil else { assertionFailure("Superview cannot be nil when adding contraints"); return nil } + translatesAutoresizingMaskIntoConstraints = false + return NSLayoutConstraint(item: self, attribute: .bottom, relatedBy: .equal, toItem: toTop, attribute: .top, multiplier: 1.0, constant: constant) + } + + func constraint(toTrailing: UIView, constant: CGFloat) -> NSLayoutConstraint? { + guard superview != nil else { assertionFailure("Superview cannot be nil when adding contraints"); return nil } + translatesAutoresizingMaskIntoConstraints = false + return NSLayoutConstraint(item: self, attribute: .leading, relatedBy: .equal, toItem: toTrailing, attribute: .trailing, multiplier: 1.0, constant: constant) + } + + func constraint(toLeading: UIView, constant: CGFloat) -> NSLayoutConstraint? { + guard superview != nil else { assertionFailure("Superview cannot be nil when adding contraints"); return nil } + translatesAutoresizingMaskIntoConstraints = false + return NSLayoutConstraint(item: self, attribute: .trailing, relatedBy: .equal, toItem: toLeading, attribute: .leading, multiplier: 1.0, constant: constant) + } + + func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat, topLayoutGuide: UILayoutSupport) + { + guard let view = superview else { assertionFailure("Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.leading, toView: view, constant: sidePadding), + NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: topLayoutGuide, attribute: .bottom, multiplier: 1.0, constant: topPadding), + constraint(.trailing, toView: view, constant: -sidePadding), + ]) + } + + func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat) { + guard let view = superview else { assertionFailure("Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.leading, toView: view, constant: sidePadding), + constraint(.top, toView: view, constant: topPadding), + constraint(.trailing, toView: view, constant: -sidePadding), + ]) + } + + func constrainTopCorners(height: CGFloat) { + guard let view = superview else { assertionFailure("Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.leading, toView: view), + constraint(.top, toView: view), + constraint(.trailing, toView: view), + constraint(.height, constant: height), + ]) + } + + func constrainBottomCorners(sidePadding: CGFloat, bottomPadding: CGFloat) { + guard let view = superview else { assertionFailure("Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.leading, toView: view, constant: sidePadding), + constraint(.bottom, toView: view, constant: -bottomPadding), + constraint(.trailing, toView: view, constant: -sidePadding), + ]) + } + + func constrainBottomCorners(height: CGFloat) { + guard let view = superview else { assertionFailure("Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.leading, toView: view), + constraint(.bottom, toView: view), + constraint(.trailing, toView: view), + constraint(.height, constant: height), + ]) + } + + func constrainLeadingCorners() { + guard let view = superview else { assertionFailure("Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.top, toView: view), + constraint(.leading, toView: view), + constraint(.bottom, toView: view), + ]) + } + + func constrainTrailingCorners() { + guard let view = superview else { assertionFailure("Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.top, toView: view), + constraint(.trailing, toView: view), + constraint(.bottom, toView: view), + ]) + } + + func constrainToCenter() { + guard let view = superview else { assertionFailure("Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.centerX, toView: view), + constraint(.centerY, toView: view), + ]) + } + + func pinTo(viewAbove: UIView, padding: CGFloat = 0.0, height: CGFloat? = nil) { + guard superview != nil else { assertionFailure("Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.width, toView: viewAbove), + constraint(toBottom: viewAbove, constant: padding), + centerXAnchor.constraint(equalTo: viewAbove.centerXAnchor), + height != nil ? constraint(.height, constant: height!) : nil, + ]) + } + + func pin(toSize: CGSize) { + guard superview != nil else { assertionFailure("Superview cannot be nil when adding contraints"); return } + constrain([ + widthAnchor.constraint(equalToConstant: toSize.width), + heightAnchor.constraint(equalToConstant: toSize.height), + ]) + } + + // Post iOS 8 + func pinTopLeft(padding: CGFloat) { + guard let view = superview else { assertionFailure("Superview cannot be nil when adding contraints"); return } + constrain([ + leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: padding), + topAnchor.constraint(equalTo: view.topAnchor, constant: padding), + ]) + } + + func pinTopLeft(toView: UIView, topPadding: CGFloat) { + guard superview != nil else { assertionFailure("Superview cannot be nil when adding contraints"); return } + constrain([ + leadingAnchor.constraint(equalTo: toView.leadingAnchor), + topAnchor.constraint(equalTo: toView.bottomAnchor, constant: topPadding), + ]) + } +} diff --git a/litewallet/src/Extensions/UIView+FrameChangeBlocking.swift b/litewallet/src/Extensions/UIView+FrameChangeBlocking.swift new file mode 100644 index 000000000..9d753dadc --- /dev/null +++ b/litewallet/src/Extensions/UIView+FrameChangeBlocking.swift @@ -0,0 +1,44 @@ +import UIKit + +extension UIView { + private struct AssociatedKeys { + static var frameBlockedKey = "FrameBlockedKey" + } + + var isFrameChangeBlocked: Bool { + get { + guard let object = objc_getAssociatedObject(self, &AssociatedKeys.frameBlockedKey) as? Bool else { return false } + return object + } + + set { + objc_setAssociatedObject(self, &AssociatedKeys.frameBlockedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + static func swizzleSetFrame() { + guard self == UIView.self else { return } + + // This is now a way to do the equivalent of dispatch_once in swift 3 + let _: () = { + let originalSelector = #selector(setter: UIView.frame) + let swizzledSelector = #selector(UIView.requestSetFrame(_:)) + + let originalMethod = class_getInstanceMethod(self, originalSelector) + let swizzledMethod = class_getInstanceMethod(self, swizzledSelector) + + let didAddMethod = class_addMethod(self, originalSelector, method_getImplementation(swizzledMethod!), method_getTypeEncoding(swizzledMethod!)) + if didAddMethod { + class_replaceMethod(self, swizzledSelector, method_getImplementation(originalMethod!), method_getTypeEncoding(originalMethod!)) + } else { + method_exchangeImplementations(originalMethod!, swizzledMethod!) + } + + }() + } + + @objc func requestSetFrame(_ frame: CGRect) { + guard !isFrameChangeBlocked else { return } + requestSetFrame(frame) + } +} diff --git a/litewallet/src/Extensions/UIView+InitAdditions.swift b/litewallet/src/Extensions/UIView+InitAdditions.swift new file mode 100644 index 000000000..9136065f0 --- /dev/null +++ b/litewallet/src/Extensions/UIView+InitAdditions.swift @@ -0,0 +1,17 @@ +import QuartzCore +import UIKit + +extension UIView { + @objc convenience init(color: UIColor) { + self.init(frame: .zero) + backgroundColor = color + } + + var imageRepresentation: UIImage { + UIGraphicsBeginImageContextWithOptions(bounds.size, isOpaque, 0.0) + layer.render(in: UIGraphicsGetCurrentContext()!) + let tempImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return tempImage! + } +} diff --git a/litewallet/src/Extensions/UIViewController+Alerts.swift b/litewallet/src/Extensions/UIViewController+Alerts.swift new file mode 100644 index 000000000..03eaaad36 --- /dev/null +++ b/litewallet/src/Extensions/UIViewController+Alerts.swift @@ -0,0 +1,15 @@ +import UIKit + +extension UIViewController { + func showErrorMessage(_ message: String) { + let alert = UIAlertController(title: S.LitewalletAlert.error.localize(), message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: S.Button.ok.localize(), style: .default, handler: nil)) + present(alert, animated: true, completion: nil) + } + + func showAlert(title: String, message: String, buttonLabel _: String) { + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: S.Button.ok.localize(), style: .default, handler: nil)) + present(alertController, animated: true, completion: nil) + } +} diff --git a/litewallet/src/Extensions/UIViewController+BRWAdditions.swift b/litewallet/src/Extensions/UIViewController+BRWAdditions.swift new file mode 100644 index 000000000..75b913ef0 --- /dev/null +++ b/litewallet/src/Extensions/UIViewController+BRWAdditions.swift @@ -0,0 +1,31 @@ +import UIKit + +extension UIViewController { + func addChildViewController(_ viewController: UIViewController, layout: () -> Void) { + addChild(viewController) + view.addSubview(viewController.view) + layout() + viewController.didMove(toParent: self) + } + + func remove() { + willMove(toParent: nil) + view.removeFromSuperview() + removeFromParent() + } + + func addCloseNavigationItem(tintColor: UIColor? = nil) { + let close = UIButton.close + close.tap = { [weak self] in + self?.dismiss(animated: true, completion: nil) + } + if let color = tintColor { + close.tintColor = UIColor.black + } + navigationItem.leftBarButtonItems = [UIBarButtonItem.negativePadding, UIBarButtonItem(customView: close)] + } + + func hideCloseNavigationItem() { + navigationItem.leftBarButtonItems = [UIBarButtonItem.negativePadding, UIBarButtonItem(customView: UIView())] + } +} diff --git a/litewallet/src/Extensions/UIViewControllerContextTransitioning+BRAdditions.swift b/litewallet/src/Extensions/UIViewControllerContextTransitioning+BRAdditions.swift new file mode 100644 index 000000000..adde78ddd --- /dev/null +++ b/litewallet/src/Extensions/UIViewControllerContextTransitioning+BRAdditions.swift @@ -0,0 +1,9 @@ +import UIKit + +extension UIViewControllerContextTransitioning { + var views: (UIView, UIView)? { + guard let fromView = view(forKey: .from) else { assertionFailure("Empty from view"); return nil } + guard let toView = view(forKey: .to) else { assertionFailure("Empty to view"); return nil } + return (fromView, toView) + } +} diff --git a/litewallet/src/Extensions/UIViewPropertyAnimator+BRWAdditions.swift b/litewallet/src/Extensions/UIViewPropertyAnimator+BRWAdditions.swift new file mode 100644 index 000000000..320650b69 --- /dev/null +++ b/litewallet/src/Extensions/UIViewPropertyAnimator+BRWAdditions.swift @@ -0,0 +1,18 @@ +import UIKit + +@available(iOS 10.0, *) +extension UIViewPropertyAnimator { + static func springAnimation(_ duration: TimeInterval, delay: TimeInterval, animations: @escaping () -> Void, completion: @escaping (UIViewAnimatingPosition) -> Void) + { + let springParameters = UISpringTimingParameters(dampingRatio: 0.7) + let animator = UIViewPropertyAnimator(duration: duration, timingParameters: springParameters) + animator.addAnimations(animations) + animator.addCompletion(completion) + animator.startAnimation(afterDelay: delay) + } + + static func springAnimation(_ duration: TimeInterval, animations: @escaping () -> Void, completion: @escaping (UIViewAnimatingPosition) -> Void) + { + springAnimation(duration, delay: 0.0, animations: animations, completion: completion) + } +} diff --git a/litewallet/src/Extensions/UserDefaults+Additions.swift b/litewallet/src/Extensions/UserDefaults+Additions.swift new file mode 100644 index 000000000..bb595aa6e --- /dev/null +++ b/litewallet/src/Extensions/UserDefaults+Additions.swift @@ -0,0 +1,188 @@ +import Foundation + +private let defaults = UserDefaults.standard +private let isBiometricsEnabledKey = "isbiometricsenabled" +private let defaultCurrencyCodeKey = "defaultcurrency" +private let hasAquiredShareDataPermissionKey = "has_acquired_permission" +private let legacyWalletNeedsBackupKey = "WALLET_NEEDS_BACKUP" +private let writePaperPhraseDateKey = "writepaperphrasedatekey" +private let hasPromptedBiometricsKey = "haspromptedtouched" +private let isLtcSwappedKey = "isLtcSwappedKey" +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 userIsInUSAKey = "userIsInUSAKey" +private let selectedLanguageKey = "selectedLanguage" + +extension UserDefaults { + static var selectedLanguage: String { + get { + guard defaults.object(forKey: selectedLanguageKey) != nil else { + return "en" + } + return defaults.string(forKey: selectedLanguageKey) ?? "en" + } + set { defaults.set(newValue, forKey: selectedLanguageKey) } + } + + static var isBiometricsEnabled: Bool { + get { + guard defaults.object(forKey: isBiometricsEnabledKey) != nil + else { + return false + } + return defaults.bool(forKey: isBiometricsEnabledKey) + } + set { defaults.set(newValue, forKey: isBiometricsEnabledKey) } + } + + static var didSeeCorruption: Bool { + get { return defaults.bool(forKey: didSeeTransactionCorruption) } + set { defaults.set(newValue, forKey: didSeeTransactionCorruption) } + } + + static var defaultCurrencyCode: String { + get { + var currencyCode = "USD" + if defaults.object(forKey: defaultCurrencyCodeKey) == nil { + currencyCode = Locale.current.currencyCode ?? "USD" + } else { + currencyCode = defaults.string(forKey: defaultCurrencyCodeKey)! + } + let acceptedCurrencyCodes = ["USD", "EUR", "JPY", "BGN", "CZK", "DKK", "GBP", "HUF", "PLN", "RON", "SEK", "CHF", "NOK", "HRK", "RUB", "TRY", "AUD", "BRL", "CAD", "CNY", "HKD", "IDR", "ILS", "INR", "KRW", "MXN", "MYR", "NZD", "PHP", "SDG", "THB", "ZAR"] + + if !(acceptedCurrencyCodes.contains(currencyCode)) { + return "USD" + } + + return currencyCode + } + set { defaults.set(newValue, forKey: defaultCurrencyCodeKey) } + } + + static var hasAquiredShareDataPermission: Bool { + get { return defaults.bool(forKey: hasAquiredShareDataPermissionKey) } + set { defaults.set(newValue, forKey: hasAquiredShareDataPermissionKey) } + } + + static var isLtcSwapped: Bool { + get { return defaults.bool(forKey: isLtcSwappedKey) + } + set { defaults.set(newValue, forKey: isLtcSwappedKey) } + } + + static var userIsInUSA: Bool { + get { return defaults.bool(forKey: userIsInUSAKey) + } + set { defaults.set(newValue, forKey: userIsInUSAKey) } + } + + // + // 2 - photons + // 5 - lites + // 8 - LTC + // + static var maxDigits: Int { + get { + guard defaults.object(forKey: maxDigitsKey) != nil + else { + return 8 /// Default to LTC + } + return defaults.integer(forKey: maxDigitsKey) + } + set { defaults.set(newValue, forKey: maxDigitsKey) } + } + + static var pushToken: Data? { + get { + guard defaults.object(forKey: pushTokenKey) != nil + else { + return nil + } + return defaults.data(forKey: pushTokenKey) + } + set { defaults.set(newValue, forKey: pushTokenKey) } + } + + static var currentRate: Rate? { + guard let data = defaults.object(forKey: currentRateKey) as? [String: Any] + else { + return nil + } + return Rate(data: data) + } + + static var currentRateData: [String: Any]? { + get { + guard let data = defaults.object(forKey: currentRateKey) as? [String: Any] + else { + return nil + } + return data + } + 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) } + } +} + +// MARK: - Wallet Requires Backup + +extension UserDefaults { + static var legacyWalletNeedsBackup: Bool? { + guard defaults.object(forKey: legacyWalletNeedsBackupKey) != nil + else { + return nil + } + return defaults.bool(forKey: legacyWalletNeedsBackupKey) + } + + static func removeLegacyWalletNeedsBackupKey() { + defaults.removeObject(forKey: legacyWalletNeedsBackupKey) + } + + static var writePaperPhraseDate: Date? { + get { return defaults.object(forKey: writePaperPhraseDateKey) as! Date? } + set { defaults.set(newValue, forKey: writePaperPhraseDateKey) } + } + + static var walletRequiresBackup: Bool { + if UserDefaults.writePaperPhraseDate != nil { + return false + } else { + return true + } + } +} + +// MARK: - Prompts + +extension UserDefaults { + static var hasPromptedBiometrics: Bool { + get { return defaults.bool(forKey: hasPromptedBiometricsKey) } + set { defaults.set(newValue, forKey: hasPromptedBiometricsKey) } + } +} diff --git a/litewallet/src/FeeManager.swift b/litewallet/src/FeeManager.swift new file mode 100644 index 000000000..1ddb579a2 --- /dev/null +++ b/litewallet/src/FeeManager.swift @@ -0,0 +1,83 @@ +import FirebaseAnalytics +import Foundation + +// this is the default that matches the mobile-api if the server is unavailable +private let defaultEconomyFeePerKB: UInt64 = 8000 // Updated Dec 2, 2024 +private let defaultRegularFeePerKB: UInt64 = 25000 +private let defaultLuxuryFeePerKB: UInt64 = 66746 +private let defaultTimestamp: UInt64 = 1_583_015_199_122 + +struct Fees: Equatable { + let luxury: UInt64 + let regular: UInt64 + let economy: UInt64 + let timestamp: UInt64 + + static var usingDefaultValues: Fees { + return Fees(luxury: defaultLuxuryFeePerKB, + regular: defaultRegularFeePerKB, + economy: defaultEconomyFeePerKB, + timestamp: defaultTimestamp) + } +} + +enum FeeType { + case regular + case economy + case luxury +} + +class FeeUpdater: Trackable { + // MARK: - Private + + private let walletManager: WalletManager + private let store: Store + private lazy var minFeePerKB: UInt64 = Fees.usingDefaultValues.economy + + private let maxFeePerKB = Fees.usingDefaultValues.luxury + private var timer: Timer? + private let feeUpdateInterval: TimeInterval = 15 // meet Nyquist for api server interval (30) + + // MARK: - Public + + init(walletManager: WalletManager, store: Store) { + self.walletManager = walletManager + self.store = store + } + + func refresh(completion: @escaping () -> Void) { + walletManager.apiClient?.feePerKb { newFees, error in + guard error == nil + else { + let properties: [String: String] = ["ERROR_MESSAGE": String(describing: error), + "ERROR_TYPE": "FEE_PER_KB"] + LWAnalytics.logEventWithParameters(itemName: ._20200112_ERR, properties: properties) + completion() + return + } + + if newFees == Fees.usingDefaultValues { + LWAnalytics.logEventWithParameters(itemName: ._20200301_DUDFPK) + self.saveEvent("wallet.didUseDefaultFeePerKB") + } + + self.store.perform(action: UpdateFees.set(newFees)) + completion() + } + + if timer == nil { + timer = Timer.scheduledTimer(timeInterval: feeUpdateInterval, + target: self, + selector: #selector(intervalRefresh), + userInfo: nil, repeats: true) + } + } + + func refresh() { + refresh(completion: {}) + } + + @objc func intervalRefresh() { + refresh(completion: {}) + } +} diff --git a/litewallet/src/FlowControllers/MessageUIPresenter.swift b/litewallet/src/FlowControllers/MessageUIPresenter.swift new file mode 100644 index 000000000..89cf3adbf --- /dev/null +++ b/litewallet/src/FlowControllers/MessageUIPresenter.swift @@ -0,0 +1,124 @@ +import MessageUI +import UIKit + +class MessageUIPresenter: NSObject, Trackable { + weak var presenter: UIViewController? + + func presentMailCompose(litecoinAddress: String, image: UIImage) { + presentMailCompose(string: "litecoin: \(litecoinAddress)", image: image) + } + + func presentMailCompose(bitcoinURL: String, image: UIImage) { + presentMailCompose(string: bitcoinURL, image: image) + } + + private func presentMailCompose(string: String, image: UIImage) { + guard MFMailComposeViewController.canSendMail() else { showEmailUnavailableAlert(); return } + originalTitleTextAttributes = UINavigationBar.appearance().titleTextAttributes + UINavigationBar.appearance().titleTextAttributes = nil + let emailView = MFMailComposeViewController() + emailView.setMessageBody(string, isHTML: false) + if let data = image.pngData() { + emailView.addAttachmentData(data, mimeType: "image/png", fileName: "litecoinqr.png") + } + emailView.mailComposeDelegate = self + present(emailView) + } + + func presentFeedbackCompose() { + guard MFMailComposeViewController.canSendMail() else { showEmailUnavailableAlert(); return } + originalTitleTextAttributes = UINavigationBar.appearance().titleTextAttributes + UINavigationBar.appearance().titleTextAttributes = nil + let emailView = MFMailComposeViewController() + emailView.setToRecipients([C.feedbackEmail]) + emailView.mailComposeDelegate = self + present(emailView) + } + + func presentSupportCompose() { + guard MFMailComposeViewController.canSendMail() else { showEmailUnavailableAlert(); return } + originalTitleTextAttributes = UINavigationBar.appearance().titleTextAttributes + UINavigationBar.appearance().titleTextAttributes = nil + let emailView = MFMailComposeViewController() + emailView.setSubject("Litewallet Support") + emailView.setToRecipients([C.supportEmail]) + emailView.setMessageBody(C.troubleshootingQuestions, isHTML: true) + emailView.mailComposeDelegate = self + present(emailView) + } + + func presentMailCompose(emailAddress: String) { + guard MFMailComposeViewController.canSendMail() else { showEmailUnavailableAlert(); return } + originalTitleTextAttributes = UINavigationBar.appearance().titleTextAttributes + UINavigationBar.appearance().titleTextAttributes = nil + let emailView = MFMailComposeViewController() + emailView.setToRecipients([emailAddress]) + emailView.mailComposeDelegate = self + saveEvent("receive.presentMailCompose") + present(emailView) + } + + func presentMessageCompose(address: String, image: UIImage) { + presentMessage(string: "litecoin: \(address)", image: image) + } + + func presentMessageCompose(bitcoinURL: String, image: UIImage) { + presentMessage(string: bitcoinURL, image: image) + } + + private func presentMessage(string: String, image: UIImage) { + guard MFMessageComposeViewController.canSendText() else { showMessageUnavailableAlert(); return } + originalTitleTextAttributes = UINavigationBar.appearance().titleTextAttributes + UINavigationBar.appearance().titleTextAttributes = nil + let textView = MFMessageComposeViewController() + textView.body = string + if let data = image.pngData() { + textView.addAttachmentData(data, typeIdentifier: "public.image", filename: "litecoinqr.png") + } + textView.messageComposeDelegate = self + saveEvent("receive.presentMessage") + present(textView) + } + + fileprivate var originalTitleTextAttributes: [NSAttributedString.Key: Any]? + + private func present(_ viewController: UIViewController) { + presenter?.view.isFrameChangeBlocked = true + presenter?.present(viewController, animated: true, completion: {}) + } + + fileprivate func dismiss(_ viewController: UIViewController) { + UINavigationBar.appearance().titleTextAttributes = originalTitleTextAttributes + viewController.dismiss(animated: true, completion: { + self.presenter?.view.isFrameChangeBlocked = false + }) + } + + private func showEmailUnavailableAlert() { + saveEvent("receive.emailUnavailable") + let alert = UIAlertController(title: S.ErrorMessages.emailUnavailableTitle.localize(), message: S.ErrorMessages.emailUnavailableMessage.localize(), preferredStyle: .alert) + alert.addAction(UIAlertAction(title: S.Button.ok.localize(), style: .default, handler: nil)) + presenter?.present(alert, animated: true, completion: nil) + } + + private func showMessageUnavailableAlert() { + saveEvent("receive.messagingUnavailable") + let alert = UIAlertController(title: S.ErrorMessages.messagingUnavailableTitle.localize(), message: S.ErrorMessages.messagingUnavailableMessage.localize(), preferredStyle: .alert) + alert.addAction(UIAlertAction(title: S.Button.ok.localize(), style: .default, handler: nil)) + presenter?.present(alert, animated: true, completion: nil) + } +} + +extension MessageUIPresenter: MFMailComposeViewControllerDelegate { + func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith _: MFMailComposeResult, error _: Error?) + { + dismiss(controller) + } +} + +extension MessageUIPresenter: MFMessageComposeViewControllerDelegate { + func messageComposeViewController(_ controller: MFMessageComposeViewController, didFinishWith _: MessageComposeResult) + { + dismiss(controller) + } +} diff --git a/litewallet/src/FlowControllers/StartFlowPresenter.swift b/litewallet/src/FlowControllers/StartFlowPresenter.swift new file mode 100644 index 000000000..a51199265 --- /dev/null +++ b/litewallet/src/FlowControllers/StartFlowPresenter.swift @@ -0,0 +1,217 @@ +import UIKit + +class StartFlowPresenter: Subscriber { + // MARK: - Public + + // MARK: - Private + + private let store: Store + private let rootViewController: UIViewController + private var navigationController: ModalNavigationController? + private let navigationControllerDelegate: StartNavigationDelegate + private let walletManager: WalletManager + private var loginViewController: UIViewController? + private let loginTransitionDelegate = LoginTransitionDelegate() + + init(store: Store, walletManager: WalletManager, rootViewController: UIViewController) { + self.store = store + self.walletManager = walletManager + self.rootViewController = rootViewController + navigationControllerDelegate = StartNavigationDelegate(store: store) + addSubscriptions() + } + + private func addSubscriptions() { + store.subscribe(self, + selector: { $0.isStartFlowVisible != $1.isStartFlowVisible }, + callback: { self.handleStartFlowChange(state: $0) }) + store.lazySubscribe(self, + selector: { $0.isLoginRequired != $1.isLoginRequired }, + callback: { self.handleLoginRequiredChange(state: $0) }) + store.subscribe(self, name: .lock, + callback: { _ in self.presentLoginFlow(isPresentedForLock: true) }) + } + + private func handleStartFlowChange(state: ReduxState) { + if state.isStartFlowVisible { + guardProtected(queue: DispatchQueue.main) { [weak self] in + self?.presentStartFlow() + } + } else { + dismissStartFlow() + } + } + + private func handleLoginRequiredChange(state: ReduxState) { + if state.isLoginRequired { + presentLoginFlow(isPresentedForLock: false) + } else { + dismissLoginFlow() + } + } + + // MARK: - SwiftUI Start Flow + + private func presentStartFlow() { + /// DOC: This is a legacy path for iPad users since SwiftUI doesnt gracefully handle presentations like iPhone + if UIDevice.current.userInterfaceIdiom == .pad { + let startViewController = StartViewController(store: store, + didTapCreate: { [weak self] in + self?.pushPinCreationViewControllerForNewWallet() + }, + didTapRecover: { [weak self] in + guard let myself = self else { return } + let recoverIntro = RecoverWalletIntroViewController(didTapNext: myself.pushRecoverWalletView) + myself.navigationController?.setClearNavbar() + myself.navigationController?.modalPresentationStyle = .fullScreen + myself.navigationController?.setNavigationBarHidden(false, animated: false) + myself.navigationController?.pushViewController(recoverIntro, animated: true) + }) + + navigationController = ModalNavigationController(rootViewController: startViewController) + navigationController?.delegate = navigationControllerDelegate + navigationController?.modalPresentationStyle = .fullScreen + } else { + let startHostingController = StartHostingController(store: store, + walletManager: walletManager) + + startHostingController.viewModel.userWantsToCreate { + self.pushPinCreationViewControllerForNewWallet() + } + + startHostingController.viewModel.userWantsToRecover { + let recoverIntro = RecoverWalletIntroViewController(didTapNext: self.pushRecoverWalletView) + self.navigationController?.setClearNavbar() + self.navigationController?.modalPresentationStyle = .fullScreen + self.navigationController?.setNavigationBarHidden(false, animated: false) + self.navigationController?.pushViewController(recoverIntro, animated: true) + } + + navigationController = ModalNavigationController(rootViewController: startHostingController) + navigationController?.delegate = navigationControllerDelegate + navigationController?.modalPresentationStyle = .fullScreen + } + + if let startFlow = navigationController { + startFlow.setNavigationBarHidden(true, animated: false) + rootViewController.present(startFlow, animated: false, completion: nil) + } + } + + private var pushRecoverWalletView: () -> Void { + return { [weak self] in + guard let myself = self else { return } + let recoverWalletViewController = EnterPhraseViewController(store: myself.store, walletManager: myself.walletManager, reason: .setSeed(myself.pushPinCreationViewForRecoveredWallet)) + myself.navigationController?.pushViewController(recoverWalletViewController, animated: true) + } + } + + private func pushPinCreationViewControllerForNewWallet() { + let pinCreationViewController = UpdatePinViewController(store: store, walletManager: walletManager, type: .creationNoPhrase, showsBackButton: true, phrase: nil) + pinCreationViewController.setPinSuccess = { [weak self] pin in + autoreleasepool { + guard self?.walletManager.setRandomSeedPhrase() != nil else { self?.handleWalletCreationError(); return } + self?.store.perform(action: WalletChange.setWalletCreationDate(Date())) + DispatchQueue.walletQueue.async { + self?.walletManager.peerManager?.connect() + DispatchQueue.main.async { + self?.pushStartPaperPhraseCreationViewController(pin: pin) + self?.store.trigger(name: .didCreateOrRecoverWallet) + } + } + } + } + + navigationController?.setNavigationBarHidden(false, animated: false) + navigationController?.setTintableBackArrow() + navigationController?.setClearNavbar() + navigationController?.pushViewController(pinCreationViewController, animated: true) + } + + private var pushPinCreationViewForRecoveredWallet: (String) -> Void { + return { [weak self] phrase in + guard let myself = self else { return } + let pinCreationView = UpdatePinViewController(store: myself.store, walletManager: myself.walletManager, type: .creationWithPhrase, showsBackButton: false, phrase: phrase) + pinCreationView.setPinSuccess = { [weak self] _ in + DispatchQueue.walletQueue.async { + self?.walletManager.peerManager?.connect() + DispatchQueue.main.async { + self?.store.trigger(name: .didCreateOrRecoverWallet) + } + } + } + myself.navigationController?.pushViewController(pinCreationView, animated: true) + } + } + + private func pushStartPaperPhraseCreationViewController(pin: String) { + let paperPhraseViewController = StartPaperPhraseViewController(store: store, callback: { [weak self] in + self?.pushWritePaperPhraseViewController(pin: pin) + }) + paperPhraseViewController.title = S.SecurityCenter.Cells.paperKeyTitle.localize() + paperPhraseViewController.navigationItem.setHidesBackButton(true, animated: false) + paperPhraseViewController.hideCloseNavigationItem() // Forces user to confirm paper-key + + navigationController?.navigationBar.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: UIColor.white, + NSAttributedString.Key.font: UIFont.customBold(size: 17.0), + ] + navigationController?.pushViewController(paperPhraseViewController, animated: true) + } + + private func pushWritePaperPhraseViewController(pin: String) { + let writeViewController = WritePaperPhraseViewController(store: store, walletManager: walletManager, pin: pin, callback: { [weak self] in + self?.pushConfirmPaperPhraseViewController(pin: pin) + }) + writeViewController.title = S.SecurityCenter.Cells.paperKeyTitle.localize() + writeViewController.hideCloseNavigationItem() + navigationController?.pushViewController(writeViewController, animated: true) + } + + private func pushConfirmPaperPhraseViewController(pin: String) { + let confirmVC = UIStoryboard(name: "Phrase", bundle: nil).instantiateViewController(withIdentifier: "ConfirmPaperPhraseViewController") as? ConfirmPaperPhraseViewController + confirmVC?.store = store + confirmVC?.walletManager = walletManager + confirmVC?.pin = pin + confirmVC?.didCompleteConfirmation = { [weak self] in + guard let myself = self else { return } + myself.store.perform(action: SimpleReduxAlert.Show(.paperKeySet(callback: { + self?.store.perform(action: HideStartFlow()) + }))) + } + navigationController?.navigationBar.tintColor = .white + if let confirmVC = confirmVC { + navigationController?.pushViewController(confirmVC, animated: true) + } + } + + private func presentLoginFlow(isPresentedForLock: Bool) { + let loginView = LoginViewController(store: store, isPresentedForLock: isPresentedForLock, walletManager: walletManager) + if isPresentedForLock { + loginView.shouldSelfDismiss = true + } + loginView.transitioningDelegate = loginTransitionDelegate + loginView.modalPresentationStyle = .overFullScreen + loginView.modalPresentationCapturesStatusBarAppearance = true + loginViewController = loginView + rootViewController.present(loginView, animated: false, completion: nil) + } + + private func handleWalletCreationError() { + let alert = UIAlertController(title: S.LitewalletAlert.error.localize(), message: "Could not create wallet", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: S.Button.ok.localize(), style: .default, handler: nil)) + navigationController?.present(alert, animated: true, completion: nil) + } + + private func dismissStartFlow() { + navigationController?.dismiss(animated: true) { [weak self] in + self?.navigationController = nil + } + } + + private func dismissLoginFlow() { + loginViewController?.dismiss(animated: true, completion: { [weak self] in + self?.loginViewController = nil + }) + } +} diff --git a/litewallet/src/FlowControllers/StartNavigationDelegate.swift b/litewallet/src/FlowControllers/StartNavigationDelegate.swift new file mode 100644 index 000000000..c6d54b5d4 --- /dev/null +++ b/litewallet/src/FlowControllers/StartNavigationDelegate.swift @@ -0,0 +1,53 @@ +import UIKit + +class StartNavigationDelegate: NSObject, UINavigationControllerDelegate { + let store: Store + + init(store: Store) { + self.store = store + } + + func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated _: Bool) + { + if viewController is RecoverWalletIntroViewController { + navigationController.navigationBar.tintColor = .white + navigationController.navigationBar.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: UIColor.white, + NSAttributedString.Key.font: UIFont.customBold(size: 17.0), + ] + navigationController.setClearNavbar() + navigationController.navigationBar.barTintColor = .clear + } + + if viewController is EnterPhraseViewController { + navigationController.navigationBar.tintColor = .darkText + navigationController.navigationBar.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: UIColor.darkText, + NSAttributedString.Key.font: UIFont.customBold(size: 17.0), + ] + navigationController.setClearNavbar() + navigationController.navigationBar.isTranslucent = false + navigationController.navigationBar.barTintColor = .whiteTint + } + + if viewController is UpdatePinViewController { + navigationController.navigationBar.tintColor = .darkText + navigationController.navigationBar.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: UIColor.darkText, + NSAttributedString.Key.font: UIFont.customBold(size: 17.0), + ] + navigationController.setClearNavbar() + } + + if viewController is UpdatePinViewController { + if let gr = navigationController.interactivePopGestureRecognizer { + navigationController.view.removeGestureRecognizer(gr) + } + } + + if viewController is StartWipeWalletViewController { + navigationController.setClearNavbar() + navigationController.setWhiteStyle() + } + } +} diff --git a/litewallet/src/FlowControllers/URLController.swift b/litewallet/src/FlowControllers/URLController.swift new file mode 100644 index 000000000..97ef92660 --- /dev/null +++ b/litewallet/src/FlowControllers/URLController.swift @@ -0,0 +1,75 @@ +import UIKit + +// DEV: This whole class should be removed. +// Need more testing. +class URLController: Trackable { + init(store: Store, walletManager: WalletManager) { + self.store = store + self.walletManager = walletManager + } + + private let store: Store + private let walletManager: WalletManager + private var xSource, xSuccess, xError, uri: String? + + func handleUrl(_ url: URL) -> Bool { + saveEvent("send.handleURL", attributes: [ + "scheme": url.scheme ?? C.null, + "host": url.host ?? C.null, + "path": url.path, + ]) + + switch url.scheme ?? "" { + case "loaf": + if let query = url.query { + for component in query.components(separatedBy: "&") { + let pair = component.components(separatedBy: "+") + if pair.count < 2 { continue } + let key = pair[0] + var value = String(component[component.index(key.endIndex, offsetBy: 2)...]) + value = (value.replacingOccurrences(of: "+", with: " ") as NSString).removingPercentEncoding! + switch key { + case "x-source": + xSource = value + case "x-success": + xSuccess = value + case "x-error": + xError = value + case "uri": + uri = value + default: + print("Key not supported: \(key)") + } + } + } + + if url.host == "scanqr" || url.path == "/scanqr" { + store.trigger(name: .scanQr) + } else if url.host == "addresslist" || url.path == "/addresslist" { + store.trigger(name: .copyWalletAddresses(xSuccess, xError)) + } else if url.path == "/address" { + if let success = xSuccess { + copyAddress(callback: success) + } + } + return true + + default: + return false + } + } + + private func copyAddress(callback: String) { + if let url = URL(string: callback), let wallet = walletManager.wallet { + let queryLength = url.query?.utf8.count ?? 0 + let callback = callback.appendingFormat("%@address=%@", queryLength > 0 ? "&" : "?", wallet.receiveAddress) + if let callbackURL = URL(string: callback) { + UIApplication.shared.open(callbackURL, options: [:], completionHandler: nil) + } + } + } + + private func present(alert: UIAlertController) { + store.trigger(name: .showAlert(alert)) + } +} diff --git a/litewallet/src/KVStoreCoordinator.swift b/litewallet/src/KVStoreCoordinator.swift new file mode 100644 index 000000000..a1d9f6e86 --- /dev/null +++ b/litewallet/src/KVStoreCoordinator.swift @@ -0,0 +1,45 @@ +import Foundation + +class KVStoreCoordinator: Subscriber { + init(store: Store, kvStore: BRReplicatedKVStore) { + self.store = store + self.kvStore = kvStore + } + + func retreiveStoredWalletInfo() { + guard !hasRetreivedInitialWalletInfo else { return } + if let walletInfo = WalletInfo(kvStore: kvStore) { + store.perform(action: WalletChange.setWalletName(walletInfo.name)) + store.perform(action: WalletChange.setWalletCreationDate(walletInfo.creationDate)) + } else { + print("no wallet info found") + } + hasRetreivedInitialWalletInfo = true + } + + func listenForWalletChanges() { + store.subscribe(self, + selector: { $0.walletState.creationDate != $1.walletState.creationDate }, + callback: { + if let existingInfo = WalletInfo(kvStore: self.kvStore) { + self.store.perform(action: WalletChange.setWalletCreationDate(existingInfo.creationDate)) + } else { + let newInfo = WalletInfo(name: $0.walletState.name) + newInfo.creationDate = $0.walletState.creationDate + self.set(newInfo) + } + }) + } + + private func set(_ info: BRKVStoreObject) { + do { + _ = try kvStore.set(info) + } catch { + print("error setting wallet info: \(error)") + } + } + + private let store: Store + private let kvStore: BRReplicatedKVStore + private var hasRetreivedInitialWalletInfo = false +} diff --git a/litewallet/src/LoginView.swift b/litewallet/src/LoginView.swift new file mode 100644 index 000000000..817b88505 --- /dev/null +++ b/litewallet/src/LoginView.swift @@ -0,0 +1,30 @@ +// +// LoginView.swift +// litewallet +// +// Created by Kerry Washington on 12/25/23. +// Copyright © 2023 Litecoin Foundation. All rights reserved. +// +import SwiftUI + +struct LoginView: View { + @ObservedObject + var viewModel: LockScreenViewModel + + init(viewModel: LockScreenViewModel) { + self.viewModel = viewModel + + /// lockScreenHeaderView + } + + var body: some View { + GeometryReader { _ in + ZStack { + Color.litewalletBlue.edgesIgnoringSafeArea(.all) + VStack { + Spacer() + } + } + } + } +} diff --git a/litewallet/LoginViewHostingController.swift b/litewallet/src/LoginViewHostingController.swift similarity index 100% rename from litewallet/LoginViewHostingController.swift rename to litewallet/src/LoginViewHostingController.swift diff --git a/litewallet/src/LoginViewModel.swift b/litewallet/src/LoginViewModel.swift new file mode 100644 index 000000000..de2f4e8d8 --- /dev/null +++ b/litewallet/src/LoginViewModel.swift @@ -0,0 +1,18 @@ +import AVFoundation +import Foundation +import SwiftUI +import UIKit + +class LoginViewModel: ObservableObject { + // MARK: - Combine Variables + + var store: Store + var walletManager: WalletManager? + var isPresentedForLock: Bool + + init(store: Store, isPresentedForLock: Bool, walletManager: WalletManager?) { + self.store = store + self.walletManager = walletManager + self.isPresentedForLock = isPresentedForLock + } +} diff --git a/litewallet/src/ModalPresenter.swift b/litewallet/src/ModalPresenter.swift new file mode 100644 index 000000000..b54f6d4f1 --- /dev/null +++ b/litewallet/src/ModalPresenter.swift @@ -0,0 +1,866 @@ +import LocalAuthentication +import SafariServices +import SwiftUI +import UIKit + +class ModalPresenter: Subscriber, Trackable { + // MARK: - Public + + var walletManager: WalletManager? + init(store: Store, walletManager: WalletManager, window: UIWindow, apiClient: BRAPIClient) { + self.store = store + self.window = window + self.walletManager = walletManager + modalTransitionDelegate = ModalTransitionDelegate(type: .regular, store: store) + wipeNavigationDelegate = StartNavigationDelegate(store: store) + noAuthApiClient = apiClient + addSubscriptions() + } + + // MARK: - Private + + private let store: Store + private let window: UIWindow + private let alertHeight: CGFloat = 260.0 + private let modalTransitionDelegate: ModalTransitionDelegate + private let messagePresenter = MessageUIPresenter() + private let securityCenterNavigationDelegate = SecurityCenterNavigationDelegate() + private let verifyPinTransitionDelegate = TransitioningDelegate() + private let noAuthApiClient: BRAPIClient + + private var currentRequest: PaymentRequest? + private var reachability = ReachabilityMonitor() + private var notReachableAlert: InAppAlert? + private let wipeNavigationDelegate: StartNavigationDelegate + + private func addSubscriptions() { + store.subscribe(self, + selector: { $0.rootModal != $1.rootModal }, + callback: { self.presentModal($0.rootModal) }) + store.subscribe(self, + selector: { $0.alert != $1.alert && $1.alert != nil }, + callback: { self.handleAlertChange($0.alert) }) + + // Subscribe to prompt actions + store.subscribe(self, name: .promptUpgradePin, callback: { _ in + self.presentUpgradePin() + }) + store.subscribe(self, name: .promptPaperKey, callback: { _ in + self.presentWritePaperKey() + }) + store.subscribe(self, name: .promptBiometrics, callback: { _ in + self.presentBiometricsSetting() + }) + store.subscribe(self, name: .promptShareData, callback: { _ in + self.promptShareData() + }) + store.subscribe(self, name: .recommendRescan, callback: { _ in + self.presentRescan() + }) + + store.subscribe(self, name: .scanQr, callback: { _ in + self.handleScanQrURL() + }) + store.subscribe(self, name: .copyWalletAddresses(nil, nil), callback: { + guard let trigger = $0 else { return } + if case let .copyWalletAddresses(success, error) = trigger { + self.handleCopyAddresses(success: success, error: error) + } + }) + reachability.didChange = { isReachable in + if isReachable { + self.hideNotReachable() + } else { + self.showNotReachable() + } + } + store.subscribe(self, name: .lightWeightAlert(""), callback: { + guard let trigger = $0 else { return } + if case let .lightWeightAlert(message) = trigger { + self.showLightWeightAlert(message: message) + } + }) + store.subscribe(self, name: .showAlert(nil), callback: { + guard let trigger = $0 else { return } + if case let .showAlert(alert) = trigger { + if let alert = alert { + self.topViewController?.present(alert, animated: true, completion: nil) + } + } + }) + } + + // MARK: - Prompts + + private func presentRescan() { + let vc = ReScanViewController(store: store) + let nc = UINavigationController(rootViewController: vc) + nc.setClearNavbar() + vc.addCloseNavigationItem() + topViewController?.present(nc, animated: true, completion: nil) + } + + func presentBiometricsSetting() { + guard let walletManager = walletManager else { return } + let biometricsSettings = BiometricsSettingsViewController(walletManager: walletManager, store: store) + biometricsSettings.addCloseNavigationItem(tintColor: .white) + let nc = ModalNavigationController(rootViewController: biometricsSettings) + biometricsSettings.presentSpendingLimit = strongify(self) { myself in + myself.pushBiometricsSpendingLimit(onNc: nc) + } + nc.setDefaultStyle() + nc.isNavigationBarHidden = true + nc.delegate = securityCenterNavigationDelegate + topViewController?.present(nc, animated: true, completion: nil) + } + + private func promptShareData() { + let shareData = ShareDataViewController(store: store) + let nc = ModalNavigationController(rootViewController: shareData) + nc.setDefaultStyle() + nc.isNavigationBarHidden = true + nc.delegate = securityCenterNavigationDelegate + shareData.addCloseNavigationItem() + topViewController?.present(nc, animated: true, completion: nil) + } + + func presentWritePaperKey() { + guard let vc = topViewController else { return } + presentWritePaperKey(fromViewController: vc) + } + + func presentUpgradePin() { + guard let walletManager = walletManager else { return } + let updatePin = UpdatePinViewController(store: store, walletManager: walletManager, type: .update) + let nc = ModalNavigationController(rootViewController: updatePin) + nc.setDefaultStyle() + nc.isNavigationBarHidden = true + nc.delegate = securityCenterNavigationDelegate + updatePin.addCloseNavigationItem() + topViewController?.present(nc, animated: true, completion: nil) + } + + private func presentModal(_ type: RootModal, configuration: ((UIViewController) -> Void)? = nil) { + guard type != .loginScan else { return presentLoginScan() } + guard let vc = rootModalViewController(type) + else { + store.perform(action: RootModalActions.Present(modal: .none)) + return + } + vc.transitioningDelegate = modalTransitionDelegate + vc.modalPresentationStyle = .overFullScreen + vc.modalPresentationCapturesStatusBarAppearance = true + configuration?(vc) + topViewController?.present(vc, animated: true, completion: { + self.store.perform(action: RootModalActions.Present(modal: .none)) + self.store.trigger(name: .hideStatusBar) + }) + } + + private func handleAlertChange(_ type: AlertType?) { + guard let type = type else { return } + presentAlert(type, completion: { + self.store.perform(action: SimpleReduxAlert.Hide()) + }) + } + + private func presentAlert(_ type: AlertType, completion: @escaping () -> Void) { + let alertView = AlertView(type: type) + guard let window = UIApplication.shared.windows.filter({ $0.isKeyWindow }).first + else { + saveEvent("ERROR: Window not found in the UIApplication window stack") + return + } + + let size = window.bounds.size + window.addSubview(alertView) + + let topConstraint = alertView.constraint(.top, toView: window, constant: size.height) + alertView.constrain([ + alertView.constraint(.width, constant: size.width), + alertView.constraint(.height, constant: alertHeight + 25.0), + alertView.constraint(.leading, toView: window, constant: nil), + topConstraint, + ]) + window.layoutIfNeeded() + + UIView.spring(0.6, animations: { + topConstraint?.constant = size.height - self.alertHeight + window.layoutIfNeeded() + }, completion: { _ in + alertView.animate() + UIView.spring(0.6, delay: 3.0, animations: { + topConstraint?.constant = size.height + window.layoutIfNeeded() + }, completion: { _ in + // TODO: - Make these callbacks generic + if case let .paperKeySet(callback) = type { + callback() + } + if case let .pinSet(callback) = type { + callback() + } + if case let .sweepSuccess(callback) = type { + callback() + } + completion() + alertView.removeFromSuperview() + }) + }) + } + + private func presentFailureAlert(_: AlertFailureType, + errorMessage: String, + completion: @escaping () -> Void) + { + let hostingViewController = UIHostingController(rootView: AlertFailureView(alertFailureType: .failedResolution, + errorMessage: errorMessage)) + + guard let window = UIApplication.shared.windows.filter({ $0.isKeyWindow }).first, + let failureAlertView = hostingViewController.view else { return } + let size = window.bounds.size + window.addSubview(failureAlertView) + + let topConstraint = failureAlertView.constraint(.top, toView: window, constant: size.height) + failureAlertView.constrain([ + failureAlertView.constraint(.width, constant: size.width), + failureAlertView.constraint(.height, constant: alertHeight + 50.0), + failureAlertView.constraint(.leading, toView: window, constant: nil), + topConstraint, + ]) + window.layoutIfNeeded() + + UIView.spring(0.6, animations: { + topConstraint?.constant = size.height - self.alertHeight + window.layoutIfNeeded() + }, completion: { _ in + UIView.spring(0.6, delay: 5.0, animations: { + topConstraint?.constant = size.height + window.layoutIfNeeded() + }, completion: { _ in + // TODO: - Make these callbacks generic + completion() + failureAlertView.removeFromSuperview() + }) + }) + } + + private func rootModalViewController(_ type: RootModal) -> UIViewController? { + switch type { + case .none: + return nil + case .send: + return makeSendView() + case .receive: + return receiveView(isRequestAmountVisible: true) + case .menu: + return menuViewController() + case .loginScan: + return nil // The scan view needs a custom presentation + case .loginAddress: + return receiveView(isRequestAmountVisible: false) + case .manageWallet: + return ModalViewController(childViewController: ManageWalletViewController(store: store), store: store) + case .wipeEmptyWallet: + return wipeEmptyView() + case .requestAmount: + guard let wallet = walletManager?.wallet else { return nil } + let requestVc = RequestAmountViewController(wallet: wallet, store: store) + requestVc.presentEmail = { [weak self] bitcoinURL, image in + self?.messagePresenter.presenter = self?.topViewController + self?.messagePresenter.presentMailCompose(bitcoinURL: bitcoinURL, image: image) + } + requestVc.presentText = { [weak self] bitcoinURL, image in + self?.messagePresenter.presenter = self?.topViewController + self?.messagePresenter.presentMessageCompose(bitcoinURL: bitcoinURL, image: image) + } + return ModalViewController(childViewController: requestVc, store: store) + } + } + + private func wipeEmptyView() -> UIViewController? { + guard let walletManager = walletManager else { return nil } + + let wipeEmptyvc = WipeEmptyWalletViewController(walletManager: walletManager, store: store, didTapYesDelete: ({ [weak self] in + guard let myself = self else { return } + myself.wipeWallet() + })) + return ModalViewController(childViewController: wipeEmptyvc, store: store) + } + + private func makeSendView() -> UIViewController? { + guard !store.state.walletState.isRescanning + else { + let alert = UIAlertController(title: S.LitewalletAlert.error.localize(), message: S.Send.isRescanning.localize(), preferredStyle: .alert) + alert.addAction(UIAlertAction(title: S.Button.ok.localize(), style: .cancel, handler: nil)) + topViewController?.present(alert, animated: true, completion: nil) + return nil + } + guard let walletManager = walletManager else { return nil } + guard let kvStore = walletManager.apiClient?.kv else { return nil } + + let sendVC = SendViewController(store: store, sender: Sender(walletManager: walletManager, kvStore: kvStore, store: store), walletManager: walletManager, initialRequest: currentRequest) + currentRequest = nil + + if store.state.isLoginRequired { + sendVC.isPresentedFromLock = true + } + + let root = ModalViewController(childViewController: sendVC, store: store) + sendVC.presentScan = presentScan(parent: root) + sendVC.presentVerifyPin = { [weak self, weak root] bodyText, callback in + guard let myself = self else { return } + guard let myroot = root else { return } + + let vc = VerifyPinViewController(bodyText: bodyText, pinLength: myself.store.state.pinLength, callback: callback) + vc.transitioningDelegate = myself.verifyPinTransitionDelegate + vc.modalPresentationStyle = .overFullScreen + vc.modalPresentationCapturesStatusBarAppearance = true + myroot.view.isFrameChangeBlocked = true + myroot.present(vc, animated: true, completion: nil) + } + sendVC.onPublishSuccess = { [weak self] in + self?.presentAlert(.sendSuccess, completion: {}) + } + + sendVC.onResolvedSuccess = { [weak self] in + self?.presentAlert(.resolvedSuccess, completion: {}) + } + + sendVC.onResolutionFailure = { [weak self] failureMessage in + self?.presentFailureAlert(.failedResolution, errorMessage: failureMessage, completion: {}) + } + return root + } + + private func receiveView(isRequestAmountVisible: Bool) -> UIViewController? { + guard let wallet = walletManager?.wallet else { return nil } + let receiveVC = ReceiveViewController(wallet: wallet, store: store, isRequestAmountVisible: isRequestAmountVisible) + let root = ModalViewController(childViewController: receiveVC, store: store) + receiveVC.presentEmail = { [weak self, weak root] address, image in + guard let root = root else { return } + self?.messagePresenter.presenter = root + self?.messagePresenter.presentMailCompose(litecoinAddress: address, image: image) + } + receiveVC.presentText = { [weak self, weak root] address, image in + guard let root = root else { return } + self?.messagePresenter.presenter = root + self?.messagePresenter.presentMessageCompose(address: address, image: image) + } + return root + } + + private func menuViewController() -> UIViewController? { + let menu = MenuViewController() + let root = ModalViewController(childViewController: menu, store: store) + menu.didTapSecurity = { [weak self, weak menu] in + self?.modalTransitionDelegate.reset() + menu?.dismiss(animated: true) { + self?.presentSecurityCenter() + } + } + + menu.didTapSupport = { [weak self, weak menu] in + menu?.dismiss(animated: true, completion: { + let urlString = FoundationSupport.dashboard + + guard let url = URL(string: urlString) else { return } + + LWAnalytics.logEventWithParameters(itemName: ._20201118_DTS) + + let vc = SFSafariViewController(url: url) + self?.topViewController?.present(vc, animated: true, completion: nil) + }) + } + menu.didTapLock = { [weak self, weak menu] in + menu?.dismiss(animated: true) { + self?.store.trigger(name: .lock) + } + } + menu.didTapSettings = { [weak self, weak menu] in + menu?.dismiss(animated: true) { + self?.presentSettings() + } + } + return root + } + + private func presentLoginScan() { + guard let top = topViewController else { return } + let present = presentScan(parent: top) + store.perform(action: RootModalActions.Present(modal: .none)) + present { paymentRequest in + guard let request = paymentRequest else { return } + self.currentRequest = request + self.presentModal(.send) + } + } + + private func presentSettings() { + guard let top = topViewController else { return } + guard let walletManager = walletManager else { return } + let settingsNav = UINavigationController() + let sections = ["About", "Wallet", "Manage", "Support", "Blockchain"] + let rows = [ + "About": [Setting(title: S.Settings.litewalletVersion.localize(), accessoryText: { + AppVersion.string + }, callback: {}), + Setting(title: S.Settings.litewalletEnvironment.localize(), accessoryText: { + var envName = "Release" + #if Debug || Testflight + envName = "Debug" + #endif + return envName + }, callback: {}), + Setting(title: S.Settings.litewalletPartners.localize(), callback: { + let partnerView = UIHostingController(rootView: PartnersView(viewModel: PartnerViewModel())) + settingsNav.pushViewController(partnerView, animated: true) + }), + Setting(title: S.Settings.socialLinks.localize(), callback: { + settingsNav.pushViewController(AboutViewController(), animated: true) + }), + + ], + "Wallet": [Setting(title: S.Settings.importTile.localize(), callback: { [weak self] in + guard let myself = self else { return } + guard let walletManager = myself.walletManager else { return } + let importNav = ModalNavigationController() + importNav.setClearNavbar() + importNav.setWhiteStyle() + let start = StartImportViewController(walletManager: walletManager, store: myself.store) + start.addCloseNavigationItem(tintColor: .white) + start.navigationItem.title = S.Import.title.localize() + importNav.viewControllers = [start] + settingsNav.dismiss(animated: true, completion: { + myself.topViewController?.present(importNav, animated: true, completion: nil) + }) + }), + Setting(title: S.Settings.wipe.localize(), callback: { [weak self] in + guard let myself = self else { return } + guard let walletManager = myself.walletManager else { return } + let nc = ModalNavigationController() + nc.setClearNavbar() + nc.setWhiteStyle() + nc.delegate = myself.wipeNavigationDelegate + let start = StartWipeWalletViewController { + let recover = EnterPhraseViewController(store: myself.store, walletManager: walletManager, reason: .validateForWipingWallet + { + myself.wipeWallet() + }) + nc.pushViewController(recover, animated: true) + } + start.addCloseNavigationItem(tintColor: .white) + start.navigationItem.title = S.WipeWallet.title.localize() + nc.viewControllers = [start] + settingsNav.dismiss(animated: true, completion: { + myself.topViewController?.present(nc, animated: true, completion: nil) + }) + }), + ], + "Manage": [ + Setting(title: S.Settings.languages.localize(), callback: strongify(self) { _ in + settingsNav.pushViewController(LanguageSelectionViewController(), animated: true) + }), + Setting(title: LAContext.biometricType() == .face ? S.Settings.faceIdLimit.localize() : S.Settings.touchIdLimit.localize(), accessoryText: { [weak self] in + guard let myself = self else { return "" } + guard let rate = myself.store.state.currentRate else { return "" } + let amount = Amount(amount: walletManager.spendingLimit, rate: rate, maxDigits: myself.store.state.maxDigits) + return amount.localCurrency + }, callback: { + self.pushBiometricsSpendingLimit(onNc: settingsNav) + }), + Setting(title: S.Settings.currency.localize(), accessoryText: { + let code = self.store.state.defaultCurrencyCode + let components: [String: String] = [NSLocale.Key.currencyCode.rawValue: code] + let identifier = Locale.identifier(fromComponents: components) + return Locale(identifier: identifier).currencyCode ?? "" + }, callback: { + guard let wm = self.walletManager else { print("NO WALLET MANAGER!"); return } + settingsNav.pushViewController(DefaultCurrencyViewController(walletManager: wm, store: self.store), animated: true) + }), + Setting(title: S.Settings.currentLocale.localize(), accessoryText: { + // Get the current locale + let currentLocale = Locale.current + + if let regionCode = currentLocale.regionCode, + let displayName = currentLocale.localizedString(forRegionCode: regionCode) + { + return displayName + } else { + return "" + } + + }, callback: { + let localeView = UIHostingController(rootView: LocaleChangeView(viewModel: LocaleChangeViewModel())) + settingsNav.pushViewController(localeView, animated: true) + }), + Setting(title: S.Settings.sync.localize(), callback: { + settingsNav.pushViewController(ReScanViewController(store: self.store), animated: true) + }), + Setting(title: S.UpdatePin.updateTitle.localize(), callback: strongify(self) { myself in + let updatePin = UpdatePinViewController(store: myself.store, walletManager: walletManager, type: .update) + settingsNav.pushViewController(updatePin, animated: true) + }), + ], + "Support": [ + Setting(title: S.Settings.shareData.localize(), callback: { + settingsNav.pushViewController(ShareDataViewController(store: self.store), animated: true) + }), + ], + "Blockchain": [ + Setting(title: S.Settings.advancedTitle.localize(), callback: { [weak self] in + guard let myself = self else { return } + guard let walletManager = myself.walletManager else { return } + let sections = ["Network"] + var networkRows = [Setting]() + networkRows = [Setting(title: "Litecoin Nodes", callback: { + let nodeSelector = NodeSelectorViewController(walletManager: walletManager) + settingsNav.pushViewController(nodeSelector, animated: true) + })] + + // TODO: Develop this feature for issues with the TXID + // if UserDefaults.didSeeCorruption { + // networkRows.append( + // Setting(title: S.WipeWallet.deleteDatabase, callback: { + // self?.deleteDatabase() + // }) + // ) + // } + + let advancedSettings = ["Network": networkRows] + let advancedSettingsVC = SettingsViewController(sections: sections, rows: advancedSettings, optionalTitle: S.Settings.advancedTitle.localize()) + settingsNav.pushViewController(advancedSettingsVC, animated: true) + }), + ], + ] + + let settings = SettingsViewController(sections: sections, rows: rows) + settings.addCloseNavigationItem() + settingsNav.viewControllers = [settings] + top.present(settingsNav, animated: true, completion: nil) + } + + private func presentScan(parent: UIViewController) -> PresentScan { + return { [weak parent] scanCompletion in + guard ScanViewController.isCameraAllowed + else { + self.saveEvent("scan.cameraDenied") + if let parent = parent { + ScanViewController.presentCameraUnavailableAlert(fromRoot: parent) + } + return + } + let vc = ScanViewController(completion: { paymentRequest in + scanCompletion(paymentRequest) + parent?.view.isFrameChangeBlocked = false + }, isValidURI: { address in + address.isValidAddress + }) + parent?.view.isFrameChangeBlocked = true + parent?.present(vc, animated: true, completion: {}) + } + } + + private func presentSecurityCenter() { + guard let walletManager = walletManager else { return } + let securityCenter = SecurityCenterViewController(store: store, walletManager: walletManager) + let nc = ModalNavigationController(rootViewController: securityCenter) + nc.setDefaultStyle() + nc.isNavigationBarHidden = true + nc.delegate = securityCenterNavigationDelegate + securityCenter.didTapPin = { [weak self] in + guard let myself = self else { return } + let updatePin = UpdatePinViewController(store: myself.store, walletManager: walletManager, type: .update) + nc.pushViewController(updatePin, animated: true) + } + securityCenter.didTapBiometrics = strongify(self) { myself in + let biometricsSettings = BiometricsSettingsViewController(walletManager: walletManager, store: myself.store) + biometricsSettings.presentSpendingLimit = { + myself.pushBiometricsSpendingLimit(onNc: nc) + } + nc.pushViewController(biometricsSettings, animated: true) + } + securityCenter.didTapPaperKey = { [weak self] in + self?.presentWritePaperKey(fromViewController: nc) + } + + window.rootViewController?.present(nc, animated: true, completion: nil) + } + + private func pushBiometricsSpendingLimit(onNc: UINavigationController) { + guard let walletManager = walletManager else { return } + + let verify = VerifyPinViewController(bodyText: S.VerifyPin.continueBody.localize(), pinLength: store.state.pinLength, callback: { [weak self] pin, vc in + guard let myself = self else { return false } + if walletManager.authenticate(pin: pin) { + vc.dismiss(animated: true, completion: { + let spendingLimit = BiometricsSpendingLimitViewController(walletManager: walletManager, store: myself.store) + onNc.pushViewController(spendingLimit, animated: true) + }) + return true + } else { + return false + } + }) + verify.transitioningDelegate = verifyPinTransitionDelegate + verify.modalPresentationStyle = .overFullScreen + verify.modalPresentationCapturesStatusBarAppearance = true + onNc.present(verify, animated: true, completion: nil) + } + + private func presentWritePaperKey(fromViewController vc: UIViewController) { + guard let walletManager = walletManager else { return } + let paperPhraseNavigationController = UINavigationController() + paperPhraseNavigationController.setClearNavbar() + paperPhraseNavigationController.setWhiteStyle() + paperPhraseNavigationController.modalPresentationStyle = .overFullScreen + let start = StartPaperPhraseViewController(store: store, callback: { [weak self] in + guard let myself = self else { return } + let verify = VerifyPinViewController(bodyText: S.VerifyPin.continueBody.localize(), pinLength: myself.store.state.pinLength, callback: { pin, vc in + if walletManager.authenticate(pin: pin) { + var write: WritePaperPhraseViewController? + write = WritePaperPhraseViewController(store: myself.store, walletManager: walletManager, pin: pin, callback: { [weak self] in + guard let myself = self else { return } + let confirmVC = UIStoryboard(name: "Phrase", bundle: nil).instantiateViewController(withIdentifier: "ConfirmPaperPhraseViewController") as? ConfirmPaperPhraseViewController + confirmVC?.store = myself.store + confirmVC?.walletManager = myself.walletManager + confirmVC?.pin = pin + confirmVC?.didCompleteConfirmation = { + confirmVC?.dismiss(animated: true, completion: { + myself.store.perform(action: SimpleReduxAlert.Show(.paperKeySet(callback: { + myself.store.perform(action: HideStartFlow()) + + }))) + }) + } + // write?.navigationItem.title = S.SecurityCenter.Cells.paperKeyTitle + if let confirm = confirmVC { + paperPhraseNavigationController.pushViewController(confirm, animated: true) + } + }) + write?.hideCloseNavigationItem() + /// write?.navigationItem.title = S.SecurityCenter.Cells.paperKeyTitle + + vc.dismiss(animated: true, completion: { + guard let write = write else { return } + paperPhraseNavigationController.pushViewController(write, animated: true) + }) + return true + } else { + return false + } + }) + verify.transitioningDelegate = self?.verifyPinTransitionDelegate + verify.modalPresentationStyle = .overFullScreen + verify.modalPresentationCapturesStatusBarAppearance = true + paperPhraseNavigationController.present(verify, animated: true, completion: nil) + }) + start.navigationItem.title = S.SecurityCenter.Cells.paperKeyTitle.localize() + + if UserDefaults.writePaperPhraseDate != nil { + start.addCloseNavigationItem(tintColor: .lightGray) + } else { + start.hideCloseNavigationItem() + } + + paperPhraseNavigationController.viewControllers = [start] + vc.present(paperPhraseNavigationController, animated: true, completion: nil) + } + + private func wipeWallet() { + let group = DispatchGroup() + let alert = UIAlertController(title: S.WipeWallet.alertTitle.localize(), message: S.WipeWallet.alertMessage.localize(), preferredStyle: .alert) + alert.addAction(UIAlertAction(title: S.Button.cancel.localize(), style: .default, handler: nil)) + alert.addAction(UIAlertAction(title: S.WipeWallet.wipe.localize(), style: .default, handler: { _ in + self.topViewController?.dismiss(animated: true, completion: { + let activity = BRActivityViewController(message: S.WipeWallet.wiping.localize()) + self.topViewController?.present(activity, animated: true, completion: nil) + + group.enter() + DispatchQueue.walletQueue.async { + self.walletManager?.peerManager?.disconnect() + group.leave() + } + + group.enter() + DispatchQueue.walletQueue.asyncAfter(deadline: .now() + 2.0) { + print("::: Pausing to show 'Wiping' Dialog") + group.leave() + } + + group.notify(queue: .main) { + if let canForceWipeWallet = (self.walletManager?.wipeWallet(pin: "forceWipe")), + canForceWipeWallet + { + self.store.trigger(name: .reinitWalletManager { + activity.dismiss(animated: true, completion: { + print("::: Reiniting the WalletManager") + }) + }) + } else { + let failure = UIAlertController(title: S.WipeWallet.failedTitle.localize(), message: S.WipeWallet.failedMessage.localize(), preferredStyle: .alert) + failure.addAction(UIAlertAction(title: S.Button.ok.localize(), style: .default, handler: nil)) + self.topViewController?.present(failure, animated: true, completion: nil) + } + } + }) + })) + topViewController?.present(alert, animated: true, completion: nil) + } + + private func handleScanQrURL() { + guard !store.state.isLoginRequired else { presentLoginScan(); return } + + if topViewController is MainViewController || topViewController is LoginViewController { + presentLoginScan() + } else { + LWAnalytics.logEventWithParameters(itemName: ._20210427_HCIEEH) + if let presented = UIApplication.shared.windows.filter({ $0.isKeyWindow }).first?.rootViewController?.presentedViewController + { + presented.dismiss(animated: true, completion: { + self.presentLoginScan() + }) + } + } + } + + private func handleCopyAddresses(success: String?, error _: String?) { + guard let walletManager = walletManager else { return } + let alert = UIAlertController(title: S.URLHandling.addressListAlertTitle.localize(), message: S.URLHandling.addressListAlertMessage.localize(), preferredStyle: .alert) + alert.addAction(UIAlertAction(title: S.Button.cancel.localize(), style: .cancel, handler: nil)) + alert.addAction(UIAlertAction(title: S.URLHandling.copy.localize(), style: .default, handler: { [weak self] _ in + guard let myself = self else { return } + let verify = VerifyPinViewController(bodyText: S.URLHandling.addressListVerifyPrompt.localize(), pinLength: myself.store.state.pinLength, callback: { [weak self] pin, view in + if walletManager.authenticate(pin: pin) { + self?.copyAllAddressesToClipboard() + view.dismiss(animated: true, completion: { + self?.store.perform(action: SimpleReduxAlert.Show(.addressesCopied)) + if let success = success, let url = URL(string: success) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + }) + return true + } else { + return false + } + }) + verify.transitioningDelegate = self?.verifyPinTransitionDelegate + verify.modalPresentationStyle = .overFullScreen + verify.modalPresentationCapturesStatusBarAppearance = true + self?.topViewController?.present(verify, animated: true, completion: nil) + })) + topViewController?.present(alert, animated: true, completion: nil) + } + + private func copyAllAddressesToClipboard() { + guard let wallet = walletManager?.wallet else { return } + let addresses = wallet.allAddresses.filter { wallet.addressIsUsed($0) } + UIPasteboard.general.string = addresses.joined(separator: "\n") + } + + private var topViewController: UIViewController? { + var viewController = window.rootViewController + while viewController?.presentedViewController != nil { + viewController = viewController?.presentedViewController + } + return viewController + } + + private func showNotReachable() { + guard notReachableAlert == nil else { return } + let alert = InAppAlert(message: S.LitewalletAlert.noInternet.localize(), image: #imageLiteral(resourceName: "BrokenCloud")) + notReachableAlert = alert + guard let window = UIApplication.shared.windows.filter({ $0.isKeyWindow }).first + else { + saveEvent("ERROR: Window not found in the UIApplication window stack") + return + } + let size = window.bounds.size + window.addSubview(alert) + let bottomConstraint = alert.bottomAnchor.constraint(equalTo: window.topAnchor, constant: 0.0) + alert.constrain([ + alert.constraint(.width, constant: size.width), + alert.constraint(.height, constant: InAppAlert.height), + alert.constraint(.leading, toView: window, constant: nil), + bottomConstraint, + ]) + window.layoutIfNeeded() + alert.bottomConstraint = bottomConstraint + alert.hide = { + self.hideNotReachable() + } + UIView.spring(C.animationDuration, animations: { + alert.bottomConstraint?.constant = InAppAlert.height + window.layoutIfNeeded() + }, completion: { _ in }) + } + + private func hideNotReachable() { + UIView.animate(withDuration: C.animationDuration, animations: { + self.notReachableAlert?.bottomConstraint?.constant = 0.0 + self.notReachableAlert?.superview?.layoutIfNeeded() + }, completion: { _ in + self.notReachableAlert?.removeFromSuperview() + self.notReachableAlert = nil + }) + } + + private func showLightWeightAlert(message: String) { + let alert = LightWeightAlert(message: message) + + guard let view = UIApplication.shared.windows.filter({ $0.isKeyWindow }).first + else { + saveEvent("ERROR: Window not found in the UIApplication window stack") + return + } + + view.addSubview(alert) + alert.constrain([ + alert.centerXAnchor.constraint(equalTo: view.centerXAnchor), + alert.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + alert.background.effect = nil + UIView.animate(withDuration: 0.6, animations: { + alert.background.effect = alert.effect + }, completion: { _ in + UIView.animate(withDuration: 0.6, delay: 1.0, options: [], animations: { + alert.background.effect = nil + }, completion: { _ in + alert.removeFromSuperview() + }) + }) + } +} + +class SecurityCenterNavigationDelegate: NSObject, UINavigationControllerDelegate { + func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated _: Bool) + { + guard let coordinator = navigationController.topViewController?.transitionCoordinator else { return } + + if coordinator.isInteractive { + coordinator.notifyWhenInteractionChanges { context in + // We only want to style the view controller if the + // pop animation wasn't cancelled + if !context.isCancelled { + self.setStyle(navigationController: navigationController, viewController: viewController) + } + } + } else { + setStyle(navigationController: navigationController, viewController: viewController) + } + } + + func setStyle(navigationController: UINavigationController, viewController: UIViewController) { + if viewController is SecurityCenterViewController { + navigationController.isNavigationBarHidden = true + } else { + navigationController.isNavigationBarHidden = false + } + + if viewController is BiometricsSettingsViewController { + navigationController.setWhiteStyle() + } else { + navigationController.setDefaultStyle() + } + } +} diff --git a/litewallet/src/Models/KeyboardNotificationInfo.swift b/litewallet/src/Models/KeyboardNotificationInfo.swift new file mode 100644 index 000000000..73c007c09 --- /dev/null +++ b/litewallet/src/Models/KeyboardNotificationInfo.swift @@ -0,0 +1,35 @@ +import UIKit + +struct KeyboardNotificationInfo { + let endFrame: CGRect + + let startFrame: CGRect + + var deltaY: CGFloat { + return endFrame.minY - startFrame.minY + } + + var animationOptions: UIView.AnimationOptions { + return UIView.AnimationOptions(rawValue: animationCurve << 16) + } + + let animationDuration: Double + + init?(_ userInfo: [AnyHashable: Any]?) { + guard let userInfo = userInfo else { return nil } + guard let endFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue, + let startFrame = userInfo[UIResponder.keyboardFrameBeginUserInfoKey] as? NSValue, + let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber, + let animationCurve = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber + else { + return nil + } + + self.endFrame = endFrame.cgRectValue + self.startFrame = startFrame.cgRectValue + self.animationDuration = animationDuration.doubleValue + self.animationCurve = animationCurve.uintValue + } + + private let animationCurve: UInt +} diff --git a/litewallet/src/Models/Rate.swift b/litewallet/src/Models/Rate.swift new file mode 100644 index 000000000..c8c2cc843 --- /dev/null +++ b/litewallet/src/Models/Rate.swift @@ -0,0 +1,71 @@ +import Foundation +import UIKit + +struct Rate { + let code: String + let name: String + let rate: Double + let lastTimestamp: Date + + var currencySymbol: String { + if let symbol = Rate.symbolMap[code] { + return symbol + } else { + let components: [String: String] = [NSLocale.Key.currencyCode.rawValue: code] + let identifier = Locale.identifier(fromComponents: components) + return Locale(identifier: identifier).currencySymbol ?? code + } + } + + static var symbolMap: [String: String] = { + var map = [String: String]() + Locale.availableIdentifiers.forEach { identifier in + let locale = Locale(identifier: identifier) + guard let code = locale.currencyCode else { return } + guard let symbol = locale.currencySymbol else { return } + + if let collision = map[code] { + if collision.utf8.count > symbol.utf8.count { + map[code] = symbol + } + } else { + map[code] = symbol + } + } + return map + }() + + var locale: Locale { + let components: [String: String] = [NSLocale.Key.currencyCode.rawValue: code] + let identifier = Locale.identifier(fromComponents: components) + return Locale(identifier: identifier) + } + + static var empty: Rate { + return Rate(code: "", name: "", rate: 0.0, lastTimestamp: Date()) + } +} + +extension Rate { + init?(data: Any) { + guard let dictionary = data as? [String: Any] else { return nil } + guard let code = dictionary["code"] as? String else { return nil } + guard let name = dictionary["name"] as? String else { return nil } + guard let rate = dictionary["n"] as? Double else { return nil } + self.init(code: code, name: name, rate: rate, lastTimestamp: Date()) + } + + var dictionary: [String: Any] { + return [ + "code": code, + "name": name, + "n": rate, + ] + } +} + +extension Rate: Equatable {} + +func == (lhs: Rate, rhs: Rate) -> Bool { + return lhs.code == rhs.code && lhs.name == rhs.name && lhs.rate == rhs.rate +} diff --git a/litewallet/src/Models/Setting.swift b/litewallet/src/Models/Setting.swift new file mode 100644 index 000000000..8708ab5e0 --- /dev/null +++ b/litewallet/src/Models/Setting.swift @@ -0,0 +1,15 @@ +import Foundation + +struct Setting { + let title: String + let accessoryText: (() -> String)? + let callback: () -> Void +} + +extension Setting { + init(title: String, callback: @escaping () -> Void) { + self.title = title + accessoryText = nil + self.callback = callback + } +} diff --git a/litewallet/src/Models/SimpleUTXO.swift b/litewallet/src/Models/SimpleUTXO.swift new file mode 100644 index 000000000..a409a103f --- /dev/null +++ b/litewallet/src/Models/SimpleUTXO.swift @@ -0,0 +1,23 @@ +import BRCore +import Foundation + +struct SimpleUTXO { + let hash: UInt256 + let index: UInt32 + let script: [UInt8] + let satoshis: UInt64 + + init?(json: [String: Any]) { + guard let txid = json["txid"] as? String, + let vout = json["vout"] as? Int, + let scriptPubKey = json["scriptPubKey"] as? String, + let satoshis = json["satoshis"] as? UInt64 else { return nil } + guard let hashData = txid.hexToData, + let scriptData = scriptPubKey.hexToData else { return nil } + + hash = hashData.reverse.uInt256 + index = UInt32(vout) + script = [UInt8](scriptData) + self.satoshis = satoshis + } +} diff --git a/litewallet/src/Models/Types.swift b/litewallet/src/Models/Types.swift new file mode 100644 index 000000000..c774c4a77 --- /dev/null +++ b/litewallet/src/Models/Types.swift @@ -0,0 +1,94 @@ +import Foundation +import UIKit + +// MARK: - Satoshis + +struct Satoshis { + let rawValue: UInt64 +} + +extension Satoshis { + init(_ rawValue: UInt64) { + self.rawValue = rawValue + } + + init(bits: Bits) { + rawValue = UInt64((bits.rawValue * 100.0).rounded(.toNearestOrEven)) + } + + init(bitcoin: Bitcoin) { + rawValue = UInt64((bitcoin.rawValue * Double(C.satoshis)).rounded(.toNearestOrEven)) + } + + init(value: Double, rate: Rate) { + rawValue = UInt64((value / rate.rate * Double(C.satoshis)).rounded(.toNearestOrEven)) + } + + init?(btcString: String) { + var decimal: Decimal = 0.0 + var amount: Decimal = 0.0 + guard Scanner(string: btcString).scanDecimal(&decimal) else { return nil } + NSDecimalMultiplyByPowerOf10(&amount, &decimal, 8, .up) + rawValue = NSDecimalNumber(decimal: amount).uint64Value + } +} + +extension Satoshis: Equatable {} + +func == (lhs: Satoshis, rhs: Satoshis) -> Bool { + return lhs.rawValue == rhs.rawValue +} + +func == (lhs: Satoshis?, rhs: UInt64) -> Bool { + return lhs?.rawValue == rhs +} + +func + (lhs: Satoshis, rhs: UInt64) -> Satoshis { + return Satoshis(lhs.rawValue + rhs) +} + +func + (lhs: Satoshis, rhs: Satoshis) -> Satoshis { + return Satoshis(lhs.rawValue + rhs.rawValue) +} + +func += (lhs: inout Satoshis, rhs: UInt64) { + lhs = lhs + rhs +} + +func > (lhs: Satoshis, rhs: UInt64) -> Bool { + return lhs.rawValue > rhs +} + +func < (lhs: Satoshis, rhs: UInt64) -> Bool { + return lhs.rawValue < rhs +} + +// MARK: - Bits + +struct Bits { + let rawValue: Double +} + +extension Bits { + init(satoshis: Satoshis) { + rawValue = Double(satoshis.rawValue) / 100.0 + } + + init?(string: String) { + guard let value = Double(string) else { return nil } + rawValue = value + } +} + +// MARK: - Bitcoin + +struct Bitcoin { + let rawValue: Double +} + +extension Bitcoin { + init?(string: String) { + guard let value = Double(string) else { return nil } + rawValue = value + } +} diff --git a/litewallet/src/PINFieldView.swift b/litewallet/src/PINFieldView.swift new file mode 100644 index 000000000..769ff65ad --- /dev/null +++ b/litewallet/src/PINFieldView.swift @@ -0,0 +1,105 @@ +import Foundation +import SwiftUI +import UIKit + +struct PINFieldView: UIViewRepresentable { + // MARK: - Combine Variables + + @Binding + var pinText: String + + @Binding + var pinIsFilled: Bool + + @Binding + var viewRect: CGRect + + // MARK: - Public Variables + + public + var isFirstResponder: Bool = false + + public + var placeholder: String = "------" + + // MARK: - Private Variables + + private + let viewKerning: CGFloat = 10.0 + + private + let maxPinDigits: Int = 6 + + init(pinText: Binding, + pinIsFilled: Binding, + viewRect: Binding) + { + _pinText = pinText + _pinIsFilled = pinIsFilled + _viewRect = viewRect + } + + func makeUIView(context: UIViewRepresentableContext) -> UITextField { + let textField = UITextField() + textField.delegate = context.coordinator + textField.font = .barlowSemiBold(size: 24.0) + textField.textAlignment = .center + textField.backgroundColor = .clear + textField.textColor = UIColor(Color.liteWalletDarkBlue) + textField.defaultTextAttributes.updateValue(viewKerning, forKey: NSAttributedString.Key.kern) + textField.keyboardType = .decimalPad + textField.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [ + NSAttributedString.Key.kern: 15.0, + NSAttributedString.Key.foregroundColor: UIColor(Color.green), + NSAttributedString.Key.font: UIFont.barlowBold(size: 17.0), + ]) + viewRect = textField.bounds + return textField + } + + func updateUIView(_: UITextField, context _: UIViewRepresentableContext) {} + + func makeCoordinator() -> PINFieldView.Coordinator { + Coordinator(parent: self) + } + + class Coordinator: NSObject, UITextFieldDelegate { + var parent: PINFieldView + + init(parent: PINFieldView) { + self.parent = parent + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + let maxLength = parent.maxPinDigits + let currentString: NSString = (textField.text ?? "") as NSString + let newString: NSString = + currentString.replacingCharacters(in: range, with: string) as NSString + + if newString.length == parent.maxPinDigits { + parent.pinText = String(newString) + parent.pinIsFilled = true + } else { + parent.pinIsFilled = false + } + + return newString.length <= maxLength + } + + func textFieldDidEndEditing(_ textField: UITextField) { + textField.resignFirstResponder() + } + } +} + +struct PINFieldView_Previews: PreviewProvider { + static var previews: some View { + Group { + HStack { + PINFieldView(pinText: .constant(""), + pinIsFilled: .constant(true), + viewRect: .constant(CGRect())) + } + } + } +} diff --git a/litewallet/src/PaymentProtocol.swift b/litewallet/src/PaymentProtocol.swift new file mode 100644 index 000000000..2f21ce87e --- /dev/null +++ b/litewallet/src/PaymentProtocol.swift @@ -0,0 +1,334 @@ +import BRCore +import Foundation + +class PaymentProtocolDetails { + internal let cPtr: UnsafeMutablePointer + internal var isManaged: Bool + + internal init(_ cPtr: UnsafeMutablePointer) { + self.cPtr = cPtr + isManaged = false + } + + init?(network: String = "main", outputs: [BRTxOutput], time: UInt64, expires: UInt64, memo: String? = nil, + paymentURL: String? = nil, merchantData: [UInt8]? = nil) + { + guard let cPtr = BRPaymentProtocolDetailsNew(network, outputs, outputs.count, time, expires, memo, paymentURL, + merchantData, merchantData?.count ?? 0) else { return nil } + self.cPtr = cPtr + isManaged = true + } + + init?(bytes: [UInt8]) { + guard let cPtr = BRPaymentProtocolDetailsParse(bytes, bytes.count) else { return nil } + self.cPtr = cPtr + isManaged = true + } + + var bytes: [UInt8] { + var bytes = [UInt8](repeating: 0, count: BRPaymentProtocolDetailsSerialize(cPtr, nil, 0)) + BRPaymentProtocolDetailsSerialize(cPtr, &bytes, bytes.count) + return bytes + } + + var network: String { // "main" or "test", default is "main" + return String(cString: cPtr.pointee.network) + } + + var outputs: [BRTxOutput] { // where to send payments, outputs[n].amount defaults to 0 + return [BRTxOutput](UnsafeBufferPointer(start: cPtr.pointee.outputs, count: cPtr.pointee.outCount)) + } + + var time: UInt64 { // request creation time, seconds since unix epoch, optional + return cPtr.pointee.time + } + + var expires: UInt64 { // when this request should be considered invalid, optional + return cPtr.pointee.expires + } + + var memo: String? { // human-readable description of request for the customer, optional + guard cPtr.pointee.memo != nil else { return nil } + return String(cString: cPtr.pointee.memo) + } + + var paymentURL: String? { // url to send payment and get payment ack, optional + guard cPtr.pointee.paymentURL != nil else { return nil } + return String(cString: cPtr.pointee.paymentURL) + } + + var merchantData: [UInt8]? { // arbitrary data to include in the payment message, optional + guard cPtr.pointee.merchantData != nil else { return nil } + return [UInt8](UnsafeBufferPointer(start: cPtr.pointee.merchantData, count: cPtr.pointee.merchDataLen)) + } + + deinit { + if isManaged { BRPaymentProtocolDetailsFree(cPtr) } + } +} + +class PaymentProtocolRequest { + internal let cPtr: UnsafeMutablePointer + internal var isManaged: Bool + private var cName: String? + private var errMsg: String? + private var didValidate: Bool = false + + internal init(_ cPtr: UnsafeMutablePointer) { + self.cPtr = cPtr + isManaged = false + } + + init?(version: UInt32 = 1, pkiType: String = "none", pkiData: [UInt8]? = nil, details: PaymentProtocolDetails, + signature: [UInt8]? = nil) + { + guard details.isManaged else { return nil } // request must be able take over memory management of details + guard let cPtr = BRPaymentProtocolRequestNew(version, pkiType, pkiData, pkiData?.count ?? 0, details.cPtr, + signature, signature?.count ?? 0) else { return nil } + details.isManaged = false + self.cPtr = cPtr + isManaged = true + } + + init?(data: Data) { + let bytes = [UInt8](data) + guard let cPtr = BRPaymentProtocolRequestParse(bytes, bytes.count) else { return nil } + self.cPtr = cPtr + isManaged = true + } + + var bytes: [UInt8] { + var bytes = [UInt8](repeating: 0, count: BRPaymentProtocolRequestSerialize(cPtr, nil, 0)) + BRPaymentProtocolRequestSerialize(cPtr, &bytes, bytes.count) + return bytes + } + + var version: UInt32 { // default is 1 + return cPtr.pointee.version + } + + var pkiType: String { // none / x509+sha256 / x509+sha1, default is "none" + return String(cString: cPtr.pointee.pkiType) + } + + var pkiData: [UInt8]? { // depends on pkiType, optional + guard cPtr.pointee.pkiData != nil else { return nil } + return [UInt8](UnsafeBufferPointer(start: cPtr.pointee.pkiData, count: cPtr.pointee.pkiDataLen)) + } + + var details: PaymentProtocolDetails { // required + return PaymentProtocolDetails(cPtr.pointee.details) + } + + var signature: [UInt8]? { // pki-dependent signature, optional + guard cPtr.pointee.signature != nil else { return nil } + return [UInt8](UnsafeBufferPointer(start: cPtr.pointee.signature, count: cPtr.pointee.sigLen)) + } + + var certs: [[UInt8]] { // array of DER encoded certificates + var certs = [[UInt8]]() + var idx = 0 + + while BRPaymentProtocolRequestCert(cPtr, nil, 0, idx) > 0 { + certs.append([UInt8](repeating: 0, count: BRPaymentProtocolRequestCert(cPtr, nil, 0, idx))) + BRPaymentProtocolRequestCert(cPtr, UnsafeMutablePointer(mutating: certs[idx]), certs[idx].count, idx) + idx = idx + 1 + } + + return certs + } + + var digest: [UInt8] { // hash of the request needed to sign or verify the request + let digest = [UInt8](repeating: 0, count: BRPaymentProtocolRequestDigest(cPtr, nil, 0)) + BRPaymentProtocolRequestDigest(cPtr, UnsafeMutablePointer(mutating: digest), digest.count) + return digest + } + + func isValid() -> Bool { + defer { didValidate = true } + + if pkiType != "none" { + var certs = [SecCertificate]() + let policies = [SecPolicy](repeating: SecPolicyCreateBasicX509(), count: 1) + var trust: SecTrust? + var trustResult = SecTrustResultType.invalid + + for c in self.certs { + if let cert = SecCertificateCreateWithData(nil, Data(bytes: c) as CFData) { certs.append(cert) } + } + + if !certs.isEmpty { + cName = SecCertificateCopySubjectSummary(certs[0]) as String? + } + + SecTrustCreateWithCertificates(certs as CFTypeRef, policies as CFTypeRef, &trust) + if let trust = trust { SecTrustEvaluate(trust, &trustResult) } // verify certificate chain + + // .unspecified indicates a positive result that wasn't decided by the user + guard trustResult == .unspecified || trustResult == .proceed + else { + errMsg = certs.count > 0 ? S.PaymentProtocol.Errors.untrustedCertificate.localize() : S.PaymentProtocol.Errors.missingCertificate.localize() + + if let trust = trust, let properties = SecTrustCopyProperties(trust) { + for prop in properties as! [[AnyHashable: Any]] { + if prop["type"] as? String != kSecPropertyTypeError as String { continue } + errMsg = errMsg! + " - " + (prop["value"] as! String) + break + } + } + + return false + } + + var status = errSecUnimplemented + var pubKey: SecKey? + if let trust = trust { pubKey = SecTrustCopyPublicKey(trust) } + + if let pubKey = pubKey, let signature = signature { + if pkiType == "x509+sha256" { + status = SecKeyRawVerify(pubKey, .PKCS1SHA256, digest, digest.count, signature, signature.count) + } else if pkiType == "x509+sha1" { + status = SecKeyRawVerify(pubKey, .PKCS1SHA1, digest, digest.count, signature, signature.count) + } + } + + guard status == errSecSuccess + else { + if status == errSecUnimplemented { + errMsg = S.PaymentProtocol.Errors.unsupportedSignatureType.localize() + print(errMsg!) + } else { + errMsg = NSError(domain: NSOSStatusErrorDomain, code: Int(status)).localizedDescription + print("SecKeyRawVerify error: " + errMsg!) + } + + return false + } + } else if !certs.isEmpty { // non-standard extention to include an un-certified request name + cName = String(data: Data(certs[0]), encoding: .utf8) + } + + guard details.expires == 0 || NSDate.timeIntervalSinceReferenceDate <= Double(details.expires) + else { + errMsg = S.PaymentProtocol.Errors.requestExpired.localize() + return false + } + + return true + } + + var commonName: String? { + if !didValidate { _ = isValid() } + return cName + } + + var errorMessage: String? { + if !didValidate { _ = isValid() } + return errMsg + } + + deinit { + if isManaged { BRPaymentProtocolRequestFree(cPtr) } + } +} + +class PaymentProtocolPayment { + internal let cPtr: UnsafeMutablePointer + internal var isManaged: Bool + + internal init(_ cPtr: UnsafeMutablePointer) { + self.cPtr = cPtr + isManaged = false + } + + init?(merchantData: [UInt8]? = nil, transactions: [BRTxRef?], refundTo: [(address: String, amount: UInt64)], + memo: String? = nil) + { + var txRefs = transactions + guard let cPtr = BRPaymentProtocolPaymentNew(merchantData, merchantData?.count ?? 0, &txRefs, txRefs.count, + refundTo.map { $0.amount }, + refundTo.map { BRAddress(string: $0.address) ?? BRAddress() }, + refundTo.count, memo) else { return nil } + self.cPtr = cPtr + isManaged = true + } + + init?(bytes: [UInt8]) { + guard let cPtr = BRPaymentProtocolPaymentParse(bytes, bytes.count) else { return nil } + self.cPtr = cPtr + isManaged = true + } + + var bytes: [UInt8] { + var bytes = [UInt8](repeating: 0, count: BRPaymentProtocolPaymentSerialize(cPtr, nil, 0)) + BRPaymentProtocolPaymentSerialize(cPtr, &bytes, bytes.count) + return bytes + } + + var merchantData: [UInt8]? { // from request->details->merchantData, optional + guard cPtr.pointee.merchantData != nil else { return nil } + return [UInt8](UnsafeBufferPointer(start: cPtr.pointee.merchantData, count: cPtr.pointee.merchDataLen)) + } + + var transactions: [BRTxRef?] { // array of signed BRTxRef to satisfy outputs from details + return [BRTxRef?](UnsafeBufferPointer(start: cPtr.pointee.transactions, count: cPtr.pointee.txCount)) + } + + var refundTo: [BRTxOutput] + { // where to send refunds, if a refund is necessary, refundTo[n].amount defaults to 0 + return [BRTxOutput](UnsafeBufferPointer(start: cPtr.pointee.refundTo, count: cPtr.pointee.refundToCount)) + } + + var memo: String? { // human-readable message for the merchant, optional + guard cPtr.pointee.memo != nil else { return nil } + return String(cString: cPtr.pointee.memo) + } + + deinit { + if isManaged { BRPaymentProtocolPaymentFree(cPtr) } + } +} + +class PaymentProtocolACK { + internal let cPtr: UnsafeMutablePointer + internal var isManaged: Bool + + internal init(_ cPtr: UnsafeMutablePointer) { + self.cPtr = cPtr + isManaged = false + } + + init?(payment: PaymentProtocolPayment, memo: String? = nil) { + guard payment.isManaged else { return nil } // ack must be able to take over memory management of payment + guard let cPtr = BRPaymentProtocolACKNew(payment.cPtr, memo) else { return nil } + payment.isManaged = false + self.cPtr = cPtr + isManaged = true + } + + init?(data: Data) { + let bytes = [UInt8](data) + guard let cPtr = BRPaymentProtocolACKParse(bytes, bytes.count) else { return nil } + self.cPtr = cPtr + isManaged = true + } + + var bytes: [UInt8] { + var bytes = [UInt8](repeating: 0, count: BRPaymentProtocolACKSerialize(cPtr, nil, 0)) + BRPaymentProtocolACKSerialize(cPtr, &bytes, bytes.count) + return bytes + } + + var payment: PaymentProtocolPayment { // payment message that triggered this ack, required + return PaymentProtocolPayment(cPtr.pointee.payment) + } + + var memo: String? { // human-readable message for customer, optional + guard cPtr.pointee.memo != nil else { return nil } + return String(cString: cPtr.pointee.memo) + } + + deinit { + if isManaged { BRPaymentProtocolACKFree(cPtr) } + } +} diff --git a/litewallet/src/PaymentRequest.swift b/litewallet/src/PaymentRequest.swift new file mode 100644 index 000000000..5e56db0ef --- /dev/null +++ b/litewallet/src/PaymentRequest.swift @@ -0,0 +1,119 @@ +import BRCore +import Foundation + +enum PaymentRequestType { + case local + case remote +} + +struct PaymentRequest { + init?(string: String) { + if var url = NSURL(string: string.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).replacingOccurrences(of: " ", with: "%20")) + { + if let scheme = url.scheme, let resourceSpecifier = url.resourceSpecifier, url.host == nil { + url = NSURL(string: "\(scheme)://\(resourceSpecifier)")! + + if url.scheme == "litecoin", let host = url.host { + toAddress = host + guard let components = url.query?.components(separatedBy: "&") else { type = .local; return } + for component in components { + let pair = component.components(separatedBy: "=") + if pair.count < 2 { continue } + let key = pair[0] + var value = String(component[component.index(key.endIndex, offsetBy: 1)...]) + value = (value.replacingOccurrences(of: "+", with: " ") as NSString).removingPercentEncoding! + + switch key { + case "amount": + amount = Satoshis(btcString: value) + case "label": + label = value + case "message": + message = value + case "r": + r = URL(string: value) + default: + print("Key not found: \(key)") + } + } + type = r == nil ? .local : .remote + return + } + } else if url.scheme == "http" || url.scheme == "https" { + type = .remote + remoteRequest = url + return + } + } + + if string.isValidAddress { + toAddress = string + type = .local + return + } + + return nil + } + + init?(data: Data) { + paymentProtocolRequest = PaymentProtocolRequest(data: data) + type = .local + } + + func fetchRemoteRequest(completion: @escaping (PaymentRequest?) -> Void) { + let request: NSMutableURLRequest + if let url = r { + request = NSMutableURLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 5.0) + } else { + request = NSMutableURLRequest(url: remoteRequest! as URL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 5.0) // TODO: - fix ! + } + + request.setValue("application/litecoin-paymentrequest", forHTTPHeaderField: "Accept") + + URLSession.shared.dataTask(with: request as URLRequest) { data, response, error in + guard error == nil else { return completion(nil) } + guard let data = data else { return completion(nil) } + guard let response = response else { return completion(nil) } + + if response.mimeType?.lowercased() == "application/litecoin-paymentrequest" { + completion(PaymentRequest(data: data)) + } else if response.mimeType?.lowercased() == "text/uri-list" { + guard let dataStringArray = String(data: data, encoding: .utf8)?.components(separatedBy: "\n") + else { + NSLog("ERROR: Data string must not be empty") + return + } + + for line in dataStringArray { + if line.hasPrefix("#") { continue } + completion(PaymentRequest(string: line)) + break + } + completion(nil) + } else { + completion(nil) + } + }.resume() + } + + static func requestString(withAddress: String, forAmount: UInt64) -> String { + let btcAmount = convertToBTC(fromSatoshis: forAmount) + return "litecoin:\(withAddress)?amount=\(btcAmount)" + } + + var toAddress: String? + let type: PaymentRequestType + var amount: Satoshis? + var label: String? + var message: String? + var remoteRequest: NSURL? + var paymentProtocolRequest: PaymentProtocolRequest? + var r: URL? +} + +private func convertToBTC(fromSatoshis: UInt64) -> String { + var decimal = Decimal(fromSatoshis) + var amount: Decimal = 0.0 + NSDecimalMultiplyByPowerOf10(&amount, &decimal, -8, .up) + return NSDecimalNumber(decimal: amount).stringValue +} diff --git a/litewallet/src/PinDigitView.swift b/litewallet/src/PinDigitView.swift new file mode 100644 index 000000000..5b68de505 --- /dev/null +++ b/litewallet/src/PinDigitView.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftUI + +struct PinDigit: Identifiable, Hashable { + let id = UUID() + let digit: String +} + +struct PinDigitView: View { + let pinDigit: PinDigit + + var body: some View { + GeometryReader { _ in + + ZStack { + VStack { + ZStack { + RoundedRectangle(cornerRadius: bigButtonCornerRadius) + .frame(height: 45, alignment: .center) + .foregroundColor(.red) + .shadow(radius: 3, x: 3.0, y: 3.0) + + Text(pinDigit.digit) + .frame(height: 45, alignment: .center) + .font(.barlowSemiBold(size: 18.0)) + .foregroundColor(.black) + } + } + } + } + } +} + +#Preview { + PinDigitView(pinDigit: PinDigit(digit: "0")) +} + +/// Inspired by https://stackoverflow.com/questions/72926965/creating-an-ios-passcode-view-with-swiftui-how-to-hide-a-textview + +import SwiftUI +struct PasscodeView: View { + @EnvironmentObject + var viewModel: StartViewModel + @Environment(\.dismiss) var dismiss + + private let maxDigits: Int = 6 + private let userPasscode = "000000" + + @State var enteredPasscode: String = "" + @FocusState var keyboardFocused: Bool + + public var body: some View { + ZStack { + HStack { + ForEach(0 ..< maxDigits, id: \.self) { + let entered = ($0 + 1) > enteredPasscode.count + + ZStack { + Image(systemName: "circle") + } + } + } + HStack { + TextField("", text: $enteredPasscode) + .opacity(1.0) + .keyboardType(.decimalPad) + .focused($keyboardFocused) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + keyboardFocused = true + } + } + } + } + .padding() + .onChange(of: enteredPasscode) { newValue in + if newValue.count == viewModel.walletManager.pinLength { + keyboardFocused = false + } + } + // .onChange(of: enteredPasscode) { _ in + // guard enteredPasscode.count == maxDigits else { return } +// + // passcodeValidation() + // } + } + + // func passcodeValidation() { + // if enteredPasscode == userPasscode { + // viewModel.isUnlocked = true + // dismiss() + // } else { + // enteredPasscode = "" + // showAlert = true + // } + // } +} diff --git a/litewallet/src/Platform/BRAPIClient.swift b/litewallet/src/Platform/BRAPIClient.swift new file mode 100644 index 000000000..c7834643a --- /dev/null +++ b/litewallet/src/Platform/BRAPIClient.swift @@ -0,0 +1,385 @@ +import BRCore +import Foundation + +let BRAPIClientErrorDomain = "BRApiClientErrorDomain" + +// these flags map to api feature flag name values +// eg "buy-bitcoin-with-cash" is a persistent name in the /me/features list +@objc public enum BRFeatureFlags: Int, CustomStringConvertible { + case buyLitecoin + + public var description: String { + switch self { + case .buyLitecoin: return "buy-litecoin" + } + } +} + +public enum BRAPIClientError: Error { + case malformedDataError + case unknownError +} + +public typealias URLSessionTaskHandler = (Data?, HTTPURLResponse?, NSError?) -> Void +public typealias URLSessionChallengeHandler = (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + +// an object which implements BRAPIAdaptor can execute API Requests on the current wallet's behalf +public protocol BRAPIAdaptor { + // execute an API request against the current wallet + func dataTaskWithRequest( + _ request: URLRequest, authenticated: Bool, retryCount: Int, + handler: @escaping URLSessionTaskHandler + ) -> URLSessionDataTask + + func url(_ path: String, args: [String: String]?) -> URL +} + +open class BRAPIClient: NSObject, URLSessionDelegate, URLSessionTaskDelegate, BRAPIAdaptor { + private var authenticator: WalletAuthenticator + + // whether or not to emit log messages from this instance of the client + private var logEnabled = true + + // proto is the transport protocol to use for talking to the API (either http or https) + var proto = "https" + + // host is the server(s) on which the API is hosted + var host = "api-prod.lite-wallet.org" + + // isFetchingAuth is set to true when a request is currently trying to renew authentication (the token) + // it is useful because fetching auth is not idempotent and not reentrant, so at most one auth attempt + // can take place at any one time + private var isFetchingAuth = false + + // used when requests are waiting for authentication to be fetched + private var authFetchGroup = DispatchGroup() + + // the NSURLSession on which all NSURLSessionTasks are executed + private lazy var session: URLSession = .init(configuration: .default, delegate: self, delegateQueue: self.queue) + + // the queue on which the NSURLSession operates + private var queue = OperationQueue() + + // convenience getter for the API endpoint + private var baseUrl: String { + return "\(proto)://\(host)" + } + + init(authenticator: WalletAuthenticator) { + self.authenticator = authenticator + } + + // prints whatever you give it if logEnabled is true + func log(_ s: String) { + if !logEnabled { + return + } + print("[BRAPIClient] \(s)") + } + + var deviceId: String { + return UserDefaults.standard.deviceID + } + + var authKey: BRKey? { + if authenticator.noWallet { return nil } + guard let keyStr = authenticator.apiAuthKey else { return nil } + var key = BRKey() + key.compressed = 1 + if BRKeySetPrivKey(&key, keyStr) == 0 { + // DEV: Comment out to get tBTC + /// #if DEBUG + /// fatalError("Unable to decode private key") + /// #endif + } + return key + } + + // MARK: Networking functions + + // Constructs a full NSURL for a given path and url parameters + public func url(_ path: String, args: [String: String]? = nil) -> URL { + func joinPath(_ k: String...) -> URL { + return URL(string: ([baseUrl] + k).joined(separator: ""))! + } + + if let args = args { + return joinPath(path + "?" + args.map { + "\($0.0.urlEscapedString)=\($0.1.urlEscapedString)" + }.joined(separator: "&")) + } else { + return joinPath(path) + } + } + + private func signRequest(_ request: URLRequest) -> URLRequest { + var mutableRequest = request + let dateHeader = mutableRequest.allHTTPHeaderFields?.get(lowercasedKey: "date") + if dateHeader == nil { + // add Date header if necessary + mutableRequest.setValue(Date().RFC1123String(), forHTTPHeaderField: "Date") + } + if let tokenData = authenticator.userAccount, + let token = tokenData["token"] as? String, + let authKey = authKey, + let signingData = mutableRequest.signingString.data(using: .utf8) + { + let sig = signingData.sha256_2.compactSign(key: authKey) + let hval = "Litewallet \(token):\(sig.base58)" + mutableRequest.setValue(hval, forHTTPHeaderField: "Authorization") + } + return mutableRequest + } + + private func decorateRequest(_ request: URLRequest) -> URLRequest { + var actualRequest = request + actualRequest.setValue("\(E.isTestnet ? 1 : 0)", forHTTPHeaderField: "X-Litecoin-Testnet") + actualRequest.setValue("\((E.isTestFlight || E.isDebug) ? 1 : 0)", forHTTPHeaderField: "X-Testflight") + actualRequest.setValue(Locale.current.identifier, forHTTPHeaderField: "Accept-Language") + return actualRequest + } + + public func dataTaskWithRequest(_ request: URLRequest, authenticated: Bool = false, + retryCount: Int = 0, handler: @escaping URLSessionTaskHandler) -> URLSessionDataTask + { + let start = Date() + var logLine = "" + if let meth = request.httpMethod, let u = request.url { + logLine = "\(meth) \(u) auth=\(authenticated) retry=\(retryCount)" + } + + // copy the request and authenticate it. retain the original request for retries + var actualRequest = decorateRequest(request) + if authenticated { + actualRequest = signRequest(actualRequest) + } + return session.dataTask(with: actualRequest, completionHandler: { data, resp, err in + DispatchQueue.main.async { + let end = Date() + let dur = Int(end.timeIntervalSince(start) * 1000) + if let httpResp = resp as? HTTPURLResponse { + var errStr = "" + if httpResp.statusCode >= 400 { + if let data = data, let s = String(data: data, encoding: .utf8) { + errStr = s + } + } + + self.log("\(logLine) -> status=\(httpResp.statusCode) duration=\(dur)ms errStr=\(errStr)") + + if authenticated, httpResp.isBreadChallenge { + self.log("\(logLine) got authentication challenge from API - will attempt to get token") + self.getToken { err in + if err != nil, retryCount < 1 { // retry once + self.log("\(logLine) error retrieving token: \(String(describing: err)) - will retry") + DispatchQueue.main.asyncAfter(deadline: DispatchTime(uptimeNanoseconds: 1)) { + self.dataTaskWithRequest( + request, authenticated: authenticated, + retryCount: retryCount + 1, handler: handler + ).resume() + } + } else if err != nil, retryCount > 0 { // fail if we already retried + self.log("\(logLine) error retrieving token: \(String(describing: err)) - will no longer retry") + handler(nil, nil, err) + } else if retryCount < 1 { // no error, so attempt the request again + self.log("\(logLine) retrieved token, so retrying the original request") + self.dataTaskWithRequest( + request, authenticated: authenticated, + retryCount: retryCount + 1, handler: handler + ).resume() + } else { + self.log("\(logLine) retried token multiple times, will not retry again") + handler(data, httpResp, err) + } + } + } else { + handler(data, httpResp, err as NSError?) + } + } else { + self.log("\(logLine) encountered connection error \(String(describing: err))") + handler(data, nil, err as NSError?) + } + } + }) + } + + // retrieve a token and save it in the keychain data for this account + private func getToken(_ handler: @escaping (NSError?) -> Void) { + if isFetchingAuth { + log("already fetching auth, waiting...") + authFetchGroup.notify(queue: DispatchQueue.main) { + handler(nil) + } + return + } + guard let authKey = authKey + else { + return handler(NSError(domain: BRAPIClientErrorDomain, code: 500, userInfo: [ + NSLocalizedDescriptionKey: S.ApiClient.notReady, + ])) + } + let authPubKey = authKey.publicKey + isFetchingAuth = true + log("auth: entering group") + authFetchGroup.enter() + var req = URLRequest(url: url("/token")) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("application/json", forHTTPHeaderField: "Accept") + let reqJson = [ + "pubKey": authPubKey.base58, + "deviceID": deviceId, + ] + do { + let dat = try JSONSerialization.data(withJSONObject: reqJson, options: []) + req.httpBody = dat + } catch let e { + log("JSON Serialization error \(e)") + isFetchingAuth = false + authFetchGroup.leave() + return handler(NSError(domain: BRAPIClientErrorDomain, code: 500, userInfo: [ + NSLocalizedDescriptionKey: S.ApiClient.jsonError, + ])) + } + session.dataTask(with: req, completionHandler: { data, resp, err in + DispatchQueue.main.async { + if let httpResp = resp as? HTTPURLResponse { + // unsuccessful response from the server + if httpResp.statusCode != 200 { + if let data = data, let s = String(data: data, encoding: .utf8) { + self.log("Token error: \(s)") + } + self.isFetchingAuth = false + self.authFetchGroup.leave() + return handler(NSError(domain: BRAPIClientErrorDomain, code: httpResp.statusCode, userInfo: [ + NSLocalizedDescriptionKey: S.ApiClient.tokenError, + ])) + } + } + if let data = data { + do { + let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) + self.log("POST /token json response: \(json)") + if let topObj = json as? [String: Any], + let tok = topObj["token"] as? String, + let uid = topObj["userID"] as? String + { + // success! store it in the keychain + var kcData = self.authenticator.userAccount ?? [AnyHashable: Any]() + kcData["token"] = tok + kcData["userID"] = uid + self.authenticator.userAccount = kcData + } + } catch let e { + self.log("JSON Deserialization error \(e)") + } + } + self.isFetchingAuth = false + self.authFetchGroup.leave() + handler(err as NSError?) + } + }).resume() + } + + // MARK: URLSession Delegate + + public func urlSession(_: URLSession, task _: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { + if challenge.protectionSpace.host == host, challenge.protectionSpace.serverTrust != nil { + log("URLSession challenge accepted!") + completionHandler(.useCredential, + URLCredential(trust: challenge.protectionSpace.serverTrust!)) + } else { + log("URLSession challenge rejected") + completionHandler(.rejectProtectionSpace, nil) + } + } + } + + public func urlSession(_: URLSession, task: URLSessionTask, willPerformHTTPRedirection _: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) + { + var actualRequest = request + if let currentReq = task.currentRequest, var curHost = currentReq.url?.host, let curScheme = currentReq.url?.scheme + { + if let curPort = currentReq.url?.port, curPort != 443, curPort != 80 { + curHost = "\(curHost):\(curPort)" + } + if curHost == host, curScheme == proto { + // follow the redirect if we're interacting with our API + actualRequest = decorateRequest(request) + log("redirecting \(String(describing: currentReq.url)) to \(String(describing: request.url))") + if let curAuth = currentReq.allHTTPHeaderFields?["Authorization"], curAuth.hasPrefix("Litewallet") + { + // add authentication because the previous request was authenticated + log("adding authentication to redirected request") + actualRequest = signRequest(actualRequest) + } + return completionHandler(actualRequest) + } + } + completionHandler(nil) + } +} + +extension Dictionary where Key == String, Value == String { + func get(lowercasedKey k: String) -> String? { + let lcKey = k.lowercased() + if let v = self[lcKey] { + return v + } + for (lk, v) in self { + if lk.lowercased() == lcKey { + return v + } + } + return nil + } +} + +private extension URLRequest { + var signingString: String { + var parts = [ + httpMethod ?? "", + "", + allHTTPHeaderFields?.get(lowercasedKey: "content-type") ?? "", + allHTTPHeaderFields?.get(lowercasedKey: "date") ?? "", + url?.resourceString ?? "", + ] + if let meth = httpMethod { + switch meth { + case "POST", "PUT", "PATCH": + if let d = httpBody, !d.isEmpty { + parts[1] = d.sha256.base58 + } + default: break + } + } + return parts.joined(separator: "\n") + } +} + +private extension HTTPURLResponse { + var isBreadChallenge: Bool { + if let headers = allHeaderFields as? [String: String], + let challenge = headers.get(lowercasedKey: "www-authenticate") + { + if challenge.lowercased().hasPrefix("Litewallet") { + return true + } + } + return false + } +} + +private extension URL { + var resourceString: String { + var urlStr = "\(path)" + if let query = query { + if query.lengthOfBytes(using: String.Encoding.utf8) > 0 { + urlStr = "\(urlStr)?\(query)" + } + } + return urlStr + } +} diff --git a/litewallet/src/Platform/BRActivityView.swift b/litewallet/src/Platform/BRActivityView.swift new file mode 100644 index 000000000..c9daaacdc --- /dev/null +++ b/litewallet/src/Platform/BRActivityView.swift @@ -0,0 +1,77 @@ +import UIKit + +class BRActivityViewController: UIViewController { + let activityView = BRActivityView() + + init(message: String) { + super.init(nibName: nil, bundle: nil) + modalTransitionStyle = .crossDissolve + + if #available(iOS 8.0, *) { + modalPresentationStyle = .overFullScreen + } + + activityView.messageLabel.text = message + view = activityView + } + + @available(*, unavailable) + public required init(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +@objc open class BRActivityView: UIView { + let activityIndicatorView = UIActivityIndicatorView(style: .large) + let boundingBoxView = UIView(frame: CGRect.zero) + let messageLabel = UILabel(frame: CGRect.zero) + + init() { + super.init(frame: CGRect.zero) + + backgroundColor = UIColor(white: 0.0, alpha: 0.5) + + boundingBoxView.backgroundColor = UIColor(white: 0.0, alpha: 0.5) + boundingBoxView.layer.cornerRadius = 12.0 + + activityIndicatorView.startAnimating() + + messageLabel.font = UIFont.boldSystemFont(ofSize: UIFont.labelFontSize) + messageLabel.textColor = UIColor.white + messageLabel.textAlignment = .center + messageLabel.shadowColor = UIColor.black + messageLabel.shadowOffset = CGSize(width: 0.0, height: 1.0) + messageLabel.numberOfLines = 0 + + addSubview(boundingBoxView) + addSubview(activityIndicatorView) + addSubview(messageLabel) + } + + @available(*, unavailable) + public required init(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override open func layoutSubviews() { + super.layoutSubviews() + + boundingBoxView.frame.size.width = 160.0 + boundingBoxView.frame.size.height = 160.0 + boundingBoxView.frame.origin.x = ceil((bounds.width / 2.0) - (boundingBoxView.frame.width / 2.0)) + boundingBoxView.frame.origin.y = ceil((bounds.height / 2.0) - (boundingBoxView.frame.height / 2.0)) + + activityIndicatorView.frame.origin.x = ceil((bounds.width / 2.0) - (activityIndicatorView.frame.width / 2.0)) + activityIndicatorView.frame.origin.y = ceil((bounds.height / 2.0) - (activityIndicatorView.frame.height / 2.0)) + + let messageLabelSize = messageLabel.sizeThatFits(CGSize(width: 160.0 - 20.0 * 2.0, height: CGFloat.greatestFiniteMagnitude)) + messageLabel.frame.size.width = messageLabelSize.width + messageLabel.frame.size.height = messageLabelSize.height + messageLabel.frame.origin.x = ceil((bounds.width / 2.0) - (messageLabel.frame.width / 2.0)) + messageLabel.frame.origin.y = ceil( + activityIndicatorView.frame.origin.y + + activityIndicatorView.frame.size.height + + ((boundingBoxView.frame.height - activityIndicatorView.frame.height) / 4.0) + - (messageLabel.frame.height / 2.0)) + } +} diff --git a/litewallet/src/Platform/BRCoding.swift b/litewallet/src/Platform/BRCoding.swift new file mode 100644 index 000000000..2d44f0b6f --- /dev/null +++ b/litewallet/src/Platform/BRCoding.swift @@ -0,0 +1,143 @@ +import Foundation + +// BRCoder/BRCoding works a lot like NSCoder/NSCoding but simpler +// instead of using optionals everywhere we just use zero values, and take advantage +// of the swift type system somewhat to make the whole api a little cleaner + +protocol BREncodable { + // return anything that is JSON-able + func encode() -> AnyObject + // zeroValue is a zero-value initializer + static func zeroValue() -> Self + // decode can be passed any value which is json-able + static func decode(_ value: AnyObject) -> Self +} + +// An object which can encode and decode values +open class BRCoder { + var data: [String: AnyObject] + + init(data: [String: AnyObject]) { + self.data = data + } + + func encode(_ obj: BREncodable, key: String) { + data[key] = obj.encode() + } + + func decode(_ key: String) -> T { + guard let d = data[key] + else { + return T.zeroValue() + } + return T.decode(d) + } +} + +// An object which may be encoded/decoded using the archiving/unarchiving classes below +protocol BRCoding { + init?(coder decoder: BRCoder) + func encode(_ coder: BRCoder) +} + +// A basic analogue of NSKeyedArchiver, except it uses JSON and uses +open class BRKeyedArchiver { + static func archivedDataWithRootObject(_ obj: BRCoding, compressed: Bool = true) -> Data { + let coder = BRCoder(data: [String: AnyObject]()) + obj.encode(coder) + do { + let j = try JSONSerialization.data(withJSONObject: coder.data, options: []) + guard let bz = (compressed ? nil : j) + else { + print("compression error") + return Data() + } + return bz + } catch let e { + print("BRKeyedArchiver unable to archive object: \(e)") + return "{}".data(using: String.Encoding.utf8)! + } + } +} + +// A basic analogue of NSKeyedUnarchiver +open class BRKeyedUnarchiver { + static func unarchiveObjectWithData(_ data: Data, compressed: Bool = true) -> T? { + do { + guard let bz = (compressed ? nil : data), + let j = try JSONSerialization.jsonObject(with: bz, options: []) as? [String: AnyObject] + else { + print("BRKeyedUnarchiver invalid json object, or invalid bz data") + return nil + } + let coder = BRCoder(data: j) + return T(coder: coder) + } catch let e { + print("BRKeyedUnarchiver unable to deserialize JSON: \(e)") + return nil + } + } +} + +// converters + +extension Date: BREncodable { + func encode() -> AnyObject { + return timeIntervalSince1970 as AnyObject + } + + public static func zeroValue() -> Date { + return dateFromTimeIntervalSince1970(0) + } + + public static func decode(_ value: AnyObject) -> Date { + let d = (value as? Double) ?? Double() + return dateFromTimeIntervalSince1970(d) + } + + static func dateFromTimeIntervalSince1970(_ d: Double) -> T { + return Date(timeIntervalSince1970: d) as! T + } +} + +extension Int: BREncodable { + func encode() -> AnyObject { + return self as AnyObject + } + + static func zeroValue() -> Int { + return 0 + } + + static func decode(_ s: AnyObject) -> Int { + return (s as? Int) ?? zeroValue() + } +} + +extension Double: BREncodable { + func encode() -> AnyObject { + return self as AnyObject + } + + static func zeroValue() -> Double { + return 0.0 + } + + static func decode(_ s: AnyObject) -> Double { + return (s as? Double) ?? zeroValue() + } +} + +extension String: BREncodable { + func encode() -> AnyObject { + return self as AnyObject + } + + static func zeroValue() -> String { + return "" + } + + static func decode(_ s: AnyObject) -> String { + return (s as? String) ?? zeroValue() + } +} diff --git a/litewallet/src/Platform/BRReplicatedKVStore.swift b/litewallet/src/Platform/BRReplicatedKVStore.swift new file mode 100644 index 000000000..bad0eca03 --- /dev/null +++ b/litewallet/src/Platform/BRReplicatedKVStore.swift @@ -0,0 +1,806 @@ +import BRCore +import Foundation +import SQLite3 + +public enum BRReplicatedKVStoreError: Error { + case sqLiteError + case replicationError + case alreadyReplicating + case conflict + case notFound + case invalidKey + case unknown + case malformedData +} + +public enum BRRemoteKVStoreError: Error { + case notFound + case conflict + case tombstone + case unknown +} + +/// An interface to a remote key value store which utilizes optimistic-locking for concurrency control +public protocol BRRemoteKVStoreAdaptor { + /// Fetch the version of the key from the remote store + /// returns a tuple of (remoteVersion, remoteDate, remoteErr?) + func ver(key: String, completionFunc: @escaping (UInt64, Date, BRRemoteKVStoreError?) -> Void) + + /// Save a new version of the key to the remote server + /// takes the value and current remote version (zero if creating) + /// returns a tuple of (remoteVersion, remoteDate, remoteErr?) + func put(_ key: String, value: [UInt8], version: UInt64, + completionFunc: @escaping (UInt64, Date, BRRemoteKVStoreError?) -> Void) + + /// Marks a key as deleted on the remote server + /// takes the current remote version (which same as put() must match the current servers time) + /// returns a tuple of (remoteVersion, remoteDate, remoteErr?) + func del(_ key: String, version: UInt64, completionFunc: @escaping (UInt64, Date, BRRemoteKVStoreError?) -> Void) + + /// Get a key from the server + /// takes the current remote version (which may optionally be zero to fetch the newest version) + /// returns a tuple of (remoteVersion, remoteDate, remoteBytes, remoteErr?) + func get(_ key: String, version: UInt64, + completionFunc: @escaping (UInt64, Date, [UInt8], BRRemoteKVStoreError?) -> Void) + + /// Get a list of all keys on the remote server + /// returns a list of tuples of (remoteKey, remoteVersion, remoteDate, remoteErr?) + func keys(_ completionFunc: @escaping ([(String, UInt64, Date, BRRemoteKVStoreError?)], BRRemoteKVStoreError?) -> Void) +} + +private func dispatch_sync_throws(_ queue: DispatchQueue, f: () throws -> Void) throws { + var e: Error? + queue.sync { + do { + try f() + } catch let caught { + e = caught + } + } + if let e = e { + throw e + } +} + +/// A key value store which can replicate its data to remote servers that utilizes optimistic locking for local +/// concurrency control +open class BRReplicatedKVStore: NSObject { + fileprivate var db: OpaquePointer? // sqlite3* + fileprivate(set) var key: BRKey + fileprivate(set) var remote: BRRemoteKVStoreAdaptor + fileprivate(set) var syncRunning = false + fileprivate var dbQueue: DispatchQueue + fileprivate let keyRegex = try! NSRegularExpression(pattern: "^[^_][\\w-]{1,255}$", options: []) + + /// Whether or not we immediately sync a key when set() or del() is called + /// by default it is off because only one sync can run at a time, and if you set or del a lot of keys + /// most operations will err out + open var syncImmediately = false + + /// Whether or not the data replicated to the serve is encrypted. Default value should always be yes, + /// this property should only be used for testing with non-sensitive data + open var encryptedReplication = true + + /// Whether the data is encrypted at rest on disk + open var encrypted = true + + static var dbPath: URL { + let fm = FileManager.default + let docsUrl = fm.urls(for: .documentDirectory, in: .userDomainMask).first! + let bundleDirUrl = docsUrl.appendingPathComponent("kvstore.sqlite3") + return bundleDirUrl + } + + init(encryptionKey: BRKey, remoteAdaptor: BRRemoteKVStoreAdaptor) throws { + key = encryptionKey + remote = remoteAdaptor + dbQueue = DispatchQueue(label: "com.litecoin.loafwallet.kvDBQueue", attributes: []) + super.init() + try openDatabase() + try migrateDatabase() + } + + /// Removes the entire database all at once. One must call openDatabase() and migrateDatabase() + /// if one wishes to use this instance again after calling this + open func rmdb() throws { + try dispatch_sync_throws(dbQueue) { + try self.checkErr(sqlite3_close(self.db), s: "rmdb - close") + try FileManager.default.removeItem(at: BRReplicatedKVStore.dbPath) + self.db = nil + } + } + + /// Creates the internal database connection. Called automatically in init() + func openDatabase() throws { + try dispatch_sync_throws(dbQueue) { + if self.db != nil { + print("Database already open") + throw BRReplicatedKVStoreError.sqLiteError + } + try self.checkErr(sqlite3_open_v2( + BRReplicatedKVStore.dbPath.absoluteString, &self.db, + SQLITE_OPEN_FULLMUTEX | SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE, nil + ), s: "opening db") + self.log("opened DB at \(BRReplicatedKVStore.dbPath.absoluteString)") + } + } + + deinit { + sqlite3_close(db) + } + + /// Creates the local database structure. Called automatically in init() + func migrateDatabase() throws { + try dispatch_sync_throws(dbQueue) { + let commands = [ + "CREATE TABLE IF NOT EXISTS dbversion (ver INT NOT NULL PRIMARY KEY ON CONFLICT REPLACE);", + "INSERT INTO dbversion (ver) VALUES (1);", + "CREATE TABLE IF NOT EXISTS kvstore (" + + " version BIGINT NOT NULL, " + + " remote_version BIGINT NOT NULL DEFAULT 0, " + + " key TEXT NOT NULL, " + + " value BLOB NOT NULL, " + + " thetime BIGINT NOT NULL, " + // server unix timestamp in MS + " deleted BOOL NOT NULL, " + + " PRIMARY KEY (key, version) " + + ");", + ] + for cmd in commands { + var stmt: OpaquePointer? + defer { + sqlite3_finalize(stmt) + } + try self.checkErr(sqlite3_prepare_v2(self.db, cmd, -1, &stmt, nil), s: "migrate prepare") + try self.checkErr(sqlite3_step(stmt), s: "migrate stmt exec") + } + } + } + + // get a key from the local database, optionally specifying a version + func get(_ key: String, version: UInt64 = 0) throws -> (UInt64, Date, Bool, [UInt8]) { + try checkKey(key) + var ret = [UInt8]() + var curVer: UInt64 = 0 + var deleted = false + var time = Date(timeIntervalSince1970: Double()) + try txn { + if version == 0 { + (curVer, _) = try self._localVersion(key) + } else { + // check for the existence of such a version + var vStmt: OpaquePointer? + defer { + sqlite3_finalize(vStmt) + } + try self.checkErr(sqlite3_prepare_v2( + self.db, "SELECT version FROM kvstore WHERE key = ? AND version = ? ORDER BY version DESC LIMIT 1", + -1, &vStmt, nil + ), s: "get - get version - prepare") + sqlite3_bind_text(vStmt, 1, NSString(string: key).utf8String, -1, nil) + sqlite3_bind_int64(vStmt, 2, Int64(version)) + try self.checkErr(sqlite3_step(vStmt), s: "get - get version - exec", r: SQLITE_ROW) + curVer = UInt64(sqlite3_column_int64(vStmt, 0)) + } + if curVer == 0 { + throw BRReplicatedKVStoreError.notFound + } + + self.log("GET key: \(key) ver: \(curVer)") + var stmt: OpaquePointer? + defer { + sqlite3_finalize(stmt) + } + try self.checkErr(sqlite3_prepare_v2( + self.db, "SELECT value, length(value), thetime, deleted FROM kvstore WHERE key=? AND version=? LIMIT 1", + -1, &stmt, nil + ), s: "get - prepare stmt") + sqlite3_bind_text(stmt, 1, NSString(string: key).utf8String, -1, nil) + sqlite3_bind_int64(stmt, 2, Int64(curVer)) + try self.checkErr(sqlite3_step(stmt), s: "get - step stmt", r: SQLITE_ROW) + let blob = sqlite3_column_blob(stmt, 0) + let blobLength = sqlite3_column_int(stmt, 1) + time = Date.withMsTimestamp(UInt64(sqlite3_column_int64(stmt, 2))) + deleted = sqlite3_column_int(stmt, 3) > 0 + ret = Array(UnsafeBufferPointer(start: blob?.assumingMemoryBound(to: UInt8.self), count: Int(blobLength))) + } + return (curVer, time, deleted, encrypted ? try decrypt(ret) : ret) + } + + /// Set the value of a key locally in the database. If syncImmediately is true (the default) then immediately + /// after successfully saving locally, replicate to server. The `localVer` key must be the same as is currently + /// stored in the database. To create a new key, pass `0` as `localVer` + func set(_ key: String, value: [UInt8], localVer: UInt64) throws -> (UInt64, Date) { + try checkKey(key) + let (newVer, time) = try _set(key, value: value, localVer: localVer) + if syncImmediately { + try syncKey(key) { _ in + self.log("SET key synced: \(key)") + } + } + return (newVer, time) + } + + fileprivate func _set(_ key: String, value: [UInt8], localVer: UInt64) throws -> (UInt64, Date) { + var newVer: UInt64 = 0 + let time = Date() + try txn { + let (curVer, _) = try self._localVersion(key) + if curVer != localVer { + self.log("set key \(key) conflict: version \(localVer) != current version \(curVer)") + throw BRReplicatedKVStoreError.conflict + } + self.log("SET key: \(key) ver: \(curVer)") + newVer = curVer + 1 + let encryptedData = self.encrypted ? try self.encrypt(value) : value + var stmt: OpaquePointer? + defer { + sqlite3_finalize(stmt) + } + try self.checkErr(sqlite3_prepare_v2(self.db, + "INSERT INTO kvstore (version, key, value, thetime, deleted) " + + "VALUES (?, ?, ?, ?, ?)", -1, &stmt, nil), s: "set - prepare stmt") + sqlite3_bind_int64(stmt, 1, Int64(newVer)) + sqlite3_bind_text(stmt, 2, NSString(string: key).utf8String, -1, nil) + sqlite3_bind_blob(stmt, 3, encryptedData, Int32(encryptedData.count), nil) + sqlite3_bind_int64(stmt, 4, Int64(time.msTimestamp())) + sqlite3_bind_int(stmt, 5, 0) + try self.checkErr(sqlite3_step(stmt), s: "set - step stmt") + } + return (newVer, time) + } + + /// Mark a key as removed locally. If syncImmediately is true (the defualt) then immediately mark the key + /// as removed on the server as well. `localVer` must match the most recent version in the local database. + func del(_ key: String, localVer: UInt64) throws -> (UInt64, Date) { + try checkKey(key) + let (newVer, time) = try _del(key, localVer: localVer) + if syncImmediately { + try syncKey(key) { _ in + self.log("DEL key synced: \(key)") + } + } + return (newVer, time) + } + + func _del(_ key: String, localVer: UInt64) throws -> (UInt64, Date) { + if localVer == 0 { + throw BRReplicatedKVStoreError.notFound + } + var newVer: UInt64 = 0 + let time = Date() + try txn { + let (curVer, _) = try self._localVersion(key) + if curVer != localVer { + self.log("del key \(key) conflict: version \(localVer) != current version \(curVer)") + throw BRReplicatedKVStoreError.conflict + } + self.log("DEL key: \(key) ver: \(curVer)") + newVer = curVer + 1 + var stmt: OpaquePointer? + defer { + sqlite3_finalize(stmt) + } + try self.checkErr(sqlite3_prepare_v2( + self.db, "INSERT INTO kvstore (version, key, value, thetime, deleted) " + + "SELECT ?, key, value, ?, ? " + + "FROM kvstore WHERE key=? AND version=?", + -1, &stmt, nil + ), s: "del - prepare stmt") + sqlite3_bind_int64(stmt, 1, Int64(newVer)) + sqlite3_bind_int64(stmt, 2, Int64(time.msTimestamp())) + sqlite3_bind_int(stmt, 3, 1) + sqlite3_bind_text(stmt, 4, NSString(string: key).utf8String, -1, nil) + sqlite3_bind_int64(stmt, 5, Int64(curVer)) + try self.checkErr(sqlite3_step(stmt), s: "del - exec stmt") + } + return (newVer, time) + } + + /// Gets the local version of the provided key, or 0 if it doesn't exist + func localVersion(_ key: String) throws -> (UInt64, Date) { + try checkKey(key) + var retVer: UInt64 = 0 + var retTime = Date(timeIntervalSince1970: Double()) + try txn { + (retVer, retTime) = try self._localVersion(key) + } + return (retVer, retTime) + } + + func _localVersion(_ key: String) throws -> (UInt64, Date) { + var stmt: OpaquePointer? + defer { + sqlite3_finalize(stmt) + } + try checkErr(sqlite3_prepare_v2( + db, "SELECT version, thetime FROM kvstore WHERE key = ? ORDER BY version DESC LIMIT 1", -1, + &stmt, nil + ), s: "get version - prepare") + sqlite3_bind_text(stmt, 1, NSString(string: key).utf8String, -1, nil) + try checkErr(sqlite3_step(stmt), s: "get version - exec", r: SQLITE_ROW) + return ( + UInt64(sqlite3_column_int64(stmt, 0)), + Date.withMsTimestamp(UInt64(sqlite3_column_int64(stmt, 1))) + ) + } + + /// Get the remote version for the key for the most recent local version of the key, if stored. + // If local key doesn't exist, return 0 + // func remoteVersion(key: String) throws -> UInt64 { + + //// return 0 + // } + func remoteVersion(_ key: String) throws -> Int + { // this would be UInt64.. but it makes the compiler crash + try checkKey(key) + var ret: UInt64 = 0 + try txn { + var stmt: OpaquePointer? + defer { + sqlite3_finalize(stmt) + } + try self.checkErr(sqlite3_prepare_v2( + self.db, "SELECT remote_version FROM kvstore WHERE key = ? ORDER BY version DESC LIMIT 1", -1, &stmt, nil + ), s: "get remote version - prepare") + sqlite3_bind_text(stmt, 1, NSString(string: key).utf8String, -1, nil) + try self.checkErr(sqlite3_step(stmt), s: "get remote version - exec", r: SQLITE_ROW) + ret = UInt64(sqlite3_column_int64(stmt, 0)) + } + return Int(ret) + } + + /// Record the remote version for the object in a new version of the local key + func setRemoteVersion(key: String, localVer: UInt64, remoteVer: UInt64) throws -> (UInt64, Date) { + try checkKey(key) + if localVer < 1 { + throw BRReplicatedKVStoreError.conflict // setRemoteVersion can't be used for creates + } + var newVer: UInt64 = 0 + let time = Date() + try txn { + let (curVer, _) = try self._localVersion(key) + if curVer != localVer { + self.log("set remote version key \(key) conflict: version \(localVer) != current version \(curVer)") + throw BRReplicatedKVStoreError.conflict + } + self.log("SET REMOTE VERSION: \(key) ver: \(localVer) remoteVer=\(remoteVer)") + newVer = curVer + 1 + var stmt: OpaquePointer? + defer { + sqlite3_finalize(stmt) + } + try self.checkErr(sqlite3_prepare_v2( + self.db, "INSERT INTO kvstore (version, key, value, thetime, deleted, remote_version) " + + "SELECT ?, key, value, ?, deleted, ? " + + "FROM kvstore WHERE key=? AND version=?", + -1, &stmt, nil + ), s: "update remote version - prepare stmt") + sqlite3_bind_int64(stmt, 1, Int64(newVer)) + sqlite3_bind_int64(stmt, 2, Int64(time.msTimestamp())) + sqlite3_bind_int64(stmt, 3, Int64(remoteVer)) + sqlite3_bind_text(stmt, 4, NSString(string: key).utf8String, -1, nil) + sqlite3_bind_int64(stmt, 5, Int64(curVer)) + try self.checkErr(sqlite3_step(stmt), s: "update remote - exec stmt") + } + return (newVer, time) + } + + /// Get a list of (key, localVer, localTime, remoteVer, deleted) + func localKeys() throws -> [(String, UInt64, Date, UInt64, Bool)] { + var ret = [(String, UInt64, Date, UInt64, Bool)]() + try txn { + var stmt: OpaquePointer? + defer { + sqlite3_finalize(stmt) + } + try self.checkErr(sqlite3_prepare_v2(self.db, + "SELECT kvs.key, kvs.version, kvs.thetime, kvs.remote_version, kvs.deleted " + + "FROM kvstore kvs " + + "INNER JOIN ( " + + " SELECT MAX(version) AS latest_version, key " + + " FROM kvstore " + + " GROUP BY key " + + ") vermax " + + "ON kvs.version = vermax.latest_version " + + "AND kvs.key = vermax.key", -1, &stmt, nil), + s: "local keys - prepare stmt") + while sqlite3_step(stmt) == SQLITE_ROW { + let key = sqlite3_column_text(stmt, 0) + let ver = sqlite3_column_int64(stmt, 1) + let date = sqlite3_column_int64(stmt, 2) + let rver = sqlite3_column_int64(stmt, 3) + let del = sqlite3_column_int(stmt, 4) + if let key = key { + ret.append(( + String(cString: key), + UInt64(ver), + Date.withMsTimestamp(UInt64(date)), + UInt64(rver), + del > 0 + )) + } + } + } + return ret + } + + /// Sync all keys to and from the remote kv store adaptor + func syncAllKeys(_ completionHandler: @escaping (BRReplicatedKVStoreError?) -> Void) { + // update all keys locally and on the remote server, replacing missing keys + // + // 1. get a list of all keys from the server + // 2. for keys that we don't have, add em + // 3. for keys that we do have, sync em + // 4. for keys that they don't have that we do, upload em + if syncRunning { + DispatchQueue.main.async { + completionHandler(.alreadyReplicating) + } + return + } + syncRunning = true + let startTime = Date() + remote.keys { keyData, err in + if let err = err { + self.log("Error fetching remote key data: \(err)") + self.syncRunning = false + return completionHandler(.replicationError) + } + var localKeyData: [(String, UInt64, Date, UInt64, Bool)] + do { + localKeyData = try self.localKeys() + } catch let e { + self.syncRunning = false + self.log("Error getting local key data: \(e)") + return completionHandler(.replicationError) + } + let allRemoteKeys = Set(keyData.map { e in e.0 }) + var allKeyData = keyData + for k in localKeyData { + if !allRemoteKeys.contains(k.0) { + // server is missing a key that we have + allKeyData.append((k.0, 0, Date(timeIntervalSince1970: Double()), nil)) + } + } + + self.log("Syncing \(allKeyData.count) keys") + var failures = 0 + let q = DispatchQueue(label: "com.litecoin.loafwallet.kvSyncQueue", attributes: DispatchQueue.Attributes.concurrent) + let grp = DispatchGroup() + let seph = DispatchSemaphore(value: 10) + + grp.enter() + q.async { + q.async { + for k in allKeyData { + grp.enter() + _ = seph.wait(timeout: DispatchTime.distantFuture) + q.async(group: grp) { + do { + try self._syncKey(k.0, remoteVer: k.1, remoteTime: k.2, remoteErr: k.3, + completionHandler: { err in + if err != nil { + failures += 1 + } + seph.signal() + grp.leave() + }) + } catch { + failures += 1 + seph.signal() + grp.leave() + } + } + } + grp.leave() + } + _ = grp.wait(timeout: DispatchTime.distantFuture) + DispatchQueue.main.async { + self.syncRunning = false + self.log("Finished syncing in \(Date().timeIntervalSince(startTime))") + completionHandler(failures > 0 ? .replicationError : nil) + } + } + } + } + + /// Sync an individual key. Normally this is only called internally and you should call syncAllKeys + func syncKey(_ key: String, remoteVersion: UInt64? = nil, remoteTime: Date? = nil, + remoteErr: BRRemoteKVStoreError? = nil, completionHandler: @escaping (BRReplicatedKVStoreError?) -> Void) throws + { + try checkKey(key) + if syncRunning { + throw BRReplicatedKVStoreError.alreadyReplicating + } + syncRunning = true + let myCompletionHandler: (_ e: BRReplicatedKVStoreError?) -> Void = { e in + completionHandler(e) + self.syncRunning = false + } + if let remoteVersion = remoteVersion, let remoteTime = remoteTime { + try _syncKey(key, remoteVer: remoteVersion, remoteTime: remoteTime, + remoteErr: remoteErr, completionHandler: myCompletionHandler) + } else { + remote.ver(key: key) { remoteVer, remoteTime, err in + _ = try? self._syncKey(key, remoteVer: remoteVer, remoteTime: remoteTime, + remoteErr: err, completionHandler: myCompletionHandler) + } + } + } + + // the syncKey kernel - this is provided so syncAllKeys can provide get a bunch of key versions at once + // and fan out the _syncKey operations + fileprivate func _syncKey(_ key: String, remoteVer: UInt64, remoteTime: Date, remoteErr: BRRemoteKVStoreError?, + completionHandler: @escaping (BRReplicatedKVStoreError?) -> Void) throws + { + // this is a basic last-write-wins strategy. data loss is possible but in general + // we will attempt to sync before making any local modifications to the data + // and concurrency will be so low that we don't really need a fancier solution than this. + // the strategy is: + // + // 1. get the remote version. this is our "lock" + // 2. along with the remote version will come the last-modified date of the remote object + // 3. if their last-modified date is newer than ours, overwrite ours + // 4. if their last-modified date is older than ours, overwrite theirs + + if !syncRunning { + throw BRReplicatedKVStoreError.unknown // how did we get here + } + + // one optimization is we keep the remote version on the most recent local version, if they match, + // there is nothing to do + let recordedRemoteVersion = try UInt64(remoteVersion(key)) + if remoteErr != .some(.notFound), remoteVer > 0, recordedRemoteVersion == remoteVer { + log("Remote version of key \(key) is the same as the one we have") + return completionHandler(nil) // this key is already up to date + } + + var localVer: UInt64 + var localTime: Date + var localDeleted: Bool + var localValue: [UInt8] + do { + (localVer, localTime, localDeleted, localValue) = try get(key) + localValue = encryptedReplication ? try encrypt(localValue) : localValue + } catch BRReplicatedKVStoreError.notFound { + // missing key locally + (localVer, localTime, localDeleted, localValue) = (0, Date(timeIntervalSince1970: Double()), false, []) + } + let (lt, rt) = (localTime.msTimestamp(), remoteTime.msTimestamp()) + + switch remoteErr { + case nil, .some(.tombstone), .some(.notFound): + if localDeleted && remoteErr == .some(.tombstone) { // was removed on both server and locally + log("Local key \(key) was deleted, and so was the remote key") + do { + _ = try setRemoteVersion(key: key, localVer: localVer, remoteVer: remoteVer) + } catch let e where e is BRReplicatedKVStoreError { + return completionHandler(e as! BRReplicatedKVStoreError) + } catch { + return completionHandler(.replicationError) + } + return completionHandler(nil) + } + + if lt > rt || lt == rt { // local is newer (or a tiebreaker) + if localDeleted { + log("Local key \(key) was deleted, removing remotely...") + remote.del(key, version: remoteVer, completionFunc: { newRemoteVer, _, delErr in + if delErr == .some(.notFound) { + self.log("Local key \(key) was already missing on the server. Ignoring") + return completionHandler(nil) + } + if let delErr = delErr { + self.log("Error deleting remote version for key \(key), error: \(delErr)") + return completionHandler(.replicationError) + } + do { + _ = try self.setRemoteVersion(key: key, localVer: localVer, remoteVer: newRemoteVer) + } catch let e where e is BRReplicatedKVStoreError { + return completionHandler(e as! BRReplicatedKVStoreError) + } catch { + return completionHandler(.replicationError) + } + self.log("Local key \(key) removed on server") + completionHandler(nil) + }) + } else { + log("Local key \(key) is newer remoteVer=\(remoteVer), updating remotely...") + // if the remote version is zero it means it doesnt yet exist on the server. set the remote version + // to "1" to create the key on the server + let useRemoteVer = remoteVer == 0 || remoteVer < recordedRemoteVersion ? 1 : remoteVer + remote.put(key, value: localValue, version: useRemoteVer, + completionFunc: { newRemoteVer, _, putErr in + if let putErr = putErr { + self.log("Error updating remote version for key \(key), newRemoteVer=\(newRemoteVer) error: \(putErr)") + return completionHandler(.replicationError) + } + do { + _ = try self.setRemoteVersion(key: key, localVer: localVer, remoteVer: newRemoteVer) + } catch let e where e is BRReplicatedKVStoreError { + return completionHandler(e as! BRReplicatedKVStoreError) + } catch { + return completionHandler(.replicationError) + } + self.log("Local key \(key) updated on server") + completionHandler(nil) + }) + } + } else { + // local is out-of-date + if remoteErr == .some(.tombstone) { + // remote is deleted + log("Remote key \(key) deleted, removing locally") + do { + let (newLocalVer, _) = try _del(key, localVer: localVer) + _ = try setRemoteVersion(key: key, localVer: newLocalVer, remoteVer: remoteVer) + } catch BRReplicatedKVStoreError.notFound { + // well a deleted key isn't found, so why do we care + } catch let e where e is BRReplicatedKVStoreError { + return completionHandler(e as! BRReplicatedKVStoreError) + } catch { + return completionHandler(.replicationError) + } + log("Remote key \(key) was removed locally") + completionHandler(nil) + } else { + log("Remote key \(key) is newer, fetching...") + // get the remote version + remote.get(key, version: remoteVer, completionFunc: { newRemoteVer, _, remoteData, getErr in + if let getErr = getErr { + self.log("Error fetching the remote value for key \(getErr), error: \(getErr)") + return completionHandler(.replicationError) + } + do { + let decryptedValue = self.encryptedReplication ? try self.decrypt(remoteData) : remoteData + let (newLocalVer, _) = try self._set(key, value: decryptedValue, localVer: localVer) + _ = try self.setRemoteVersion(key: key, localVer: newLocalVer, remoteVer: newRemoteVer) + } catch BRReplicatedKVStoreError.malformedData { + _ = try? self.del(key, localVer: localVer) + return completionHandler(BRReplicatedKVStoreError.malformedData) + } catch let e where e is BRReplicatedKVStoreError { + return completionHandler(e as! BRReplicatedKVStoreError) + } catch { + return completionHandler(.replicationError) + } + self.log("Updated local key \(key)") + completionHandler(nil) + }) + } + } + default: + log("Error fetching remote version for key \(key), error: \(String(describing: remoteErr))") + completionHandler(.replicationError) + } + } + + // execute a function inside a transaction, if that function throws then rollback, otherwise commit + // calling txn() from within a txn function will deadlock + fileprivate func txn(_ fn: () throws -> Void) throws { + try dispatch_sync_throws(dbQueue) { + var beginStmt: OpaquePointer? + var finishStmt: OpaquePointer? + defer { + sqlite3_finalize(beginStmt) + sqlite3_finalize(finishStmt) + } + try self.checkErr(sqlite3_prepare_v2(self.db, "BEGIN", -1, &beginStmt, nil), s: "txn - prepare begin") + try self.checkErr(sqlite3_step(beginStmt), s: "txn - exec begin begin") + do { + try fn() + } catch let e { + try self.checkErr(sqlite3_prepare_v2( + self.db, "ROLLBACK", -1, &finishStmt, nil + ), s: "txn - prepare rollback") + try self.checkErr(sqlite3_step(finishStmt), s: "txn - execute rollback") + throw e + } + try self.checkErr(sqlite3_prepare_v2(self.db, "COMMIT", -1, &finishStmt, nil), s: "txn - prepare commit") + try self.checkErr(sqlite3_step(finishStmt), s: "txn - execute commit") + } + } + + // ensure the sqlite3 error code is an acceptable one (or that its the one you provide as `r` + // this MUST be called from within the dbQueue + fileprivate func checkErr(_ e: Int32, s: String, r: Int32 = SQLITE_NULL) throws { + if r == SQLITE_NULL, e != SQLITE_OK, e != SQLITE_DONE, e != SQLITE_ROW, + e != SQLITE_NULL, e != r + { + let es = NSString(cString: sqlite3_errstr(e), encoding: String.Encoding.utf8.rawValue) + let em = NSString(cString: sqlite3_errmsg(db), encoding: String.Encoding.utf8.rawValue) + log("\(s): errcode=\(e) errstr=\(String(describing: es)) errmsg=\(String(describing: em))") + throw BRReplicatedKVStoreError.sqLiteError + } + } + + // validates the key. keys can not start with a _ + fileprivate func checkKey(_ key: String) throws { + let m = keyRegex.matches(in: key, options: [], range: NSMakeRange(0, key.count)) + if m.count != 1 { + throw BRReplicatedKVStoreError.invalidKey + } + } + + // encrypt some data using self.key + fileprivate func encrypt(_ data: [UInt8]) throws -> [UInt8] { + return [UInt8](Data(data).chacha20Poly1305AEADEncrypt(key: key)) + } + + // decrypt some data using self.key + fileprivate func decrypt(_ data: [UInt8]) throws -> [UInt8] { + return try [UInt8](Data(data).chacha20Poly1305AEADDecrypt(key: key)) + } + + // generate a nonce using microseconds-since-epoch + fileprivate func genNonce() -> [UInt8] { + var tv = timeval() + gettimeofday(&tv, nil) + var t = UInt64(tv.tv_usec) * 1_000_000 + UInt64(tv.tv_usec) + let p = [UInt8](repeating: 0, count: 4) + return Data(bytes: &t, count: MemoryLayout.size).withUnsafeBytes + { (dat: UnsafePointer) -> [UInt8] in + let buf = UnsafeBufferPointer(start: dat, count: MemoryLayout.size) + return p + Array(buf) + } + } + + fileprivate func log(_ s: String) { + print("[KVStore] \(s)") + } +} + +// MARK: - Objective-C compatability layer + +@objc open class BRKVStoreObject: NSObject { + open var version: UInt64 + open var lastModified: Date + open var deleted: Bool + open var key: String + + fileprivate var _data: Data? + + var data: Data { + get { + return getData() ?? _data ?? Data() // allow subclasses to override the data that is retrieved + } + set(v) { + _data = v + dataWasSet(v) + } + } + + init(key: String, version: UInt64, lastModified: Date, deleted: Bool, data: Data) { + self.version = version + self.key = key + self.lastModified = lastModified + self.deleted = deleted + super.init() + self.data = data + } + + func getData() -> Data? { return nil } + + func dataWasSet(_: Data) {} +} + +public extension BRReplicatedKVStore { + func get(_ key: String) throws -> BRKVStoreObject { + let (v, d, r, b) = try get(key) + return BRKVStoreObject(key: key, version: v, lastModified: d, deleted: r, + data: Data(bytes: UnsafePointer(b), count: b.count)) + } + + func set(_ object: BRKVStoreObject) throws -> BRKVStoreObject { + let dat = object.data + var bytes = [UInt8](repeating: 0, count: dat.count) + (dat as NSData).getBytes(&bytes, length: dat.count) + (object.version, object.lastModified) = try set(object.key, value: bytes, localVer: object.version) + return object + } + + func del(_ object: BRKVStoreObject) throws -> BRKVStoreObject { + (object.version, object.lastModified) = try del(object.key, localVer: object.version) + object.deleted = true + return object + } +} diff --git a/litewallet/src/Platform/BRSocketHelpers.c b/litewallet/src/Platform/BRSocketHelpers.c new file mode 100644 index 000000000..6f45926ec --- /dev/null +++ b/litewallet/src/Platform/BRSocketHelpers.c @@ -0,0 +1,105 @@ +// +// BRSocketHelpers.c +// BreadWallet +// +// Created by Samuel Sutch on 2/17/16. +// Copyright (c) 2016 breadwallet LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +#include "BRSocketHelpers.h" +#include +#include +#include +#include +#include + +int bw_nbioify(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0) return flags; + flags = flags &~ O_NONBLOCK; + return fcntl(fd, F_SETFL, flags); +} + +struct bw_select_result bw_select(struct bw_select_request request) { + fd_set read_fds, write_fds, err_fds; + FD_ZERO(&read_fds); + FD_ZERO(&write_fds); + FD_ZERO(&err_fds); + int max_fd = 0; + // copy requested file descriptors from request to fd_sets + for (int i = 0; i < request.read_fd_len; i++) { + int fd = request.read_fds[i]; + if (fd > max_fd) max_fd = fd; + // printf("bw_select: read fd=%i open=%i\n", fd, fcntl(fd, F_GETFD)); + FD_SET(fd, &read_fds); + } + for (int i = 0; i < request.write_fd_len; i++) { + int fd = request.write_fds[i]; + if (fd > max_fd) max_fd = fd; + // printf("bw_select: write fd=%i open=%i\n", fd, fcntl(fd, F_GETFD)); + FD_SET(fd, &write_fds); + } + + struct bw_select_result result = { 0, 0, 0, 0, NULL, NULL, NULL }; + // printf("bw_select max_fd=%i\n", max_fd); + + // initiate a select + struct timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 10000; // 10ms + int activity = select(max_fd + 1, &read_fds, &write_fds, &err_fds, &tv); + if (activity < 0 && errno != EINTR) { + result.error = errno; + perror("select"); + return result; + } + // indicate to the caller which file descriptors are ready for reading + for (int i = 0; i < request.read_fd_len; i++) { + int fd = request.read_fds[i]; + // printf("bw_select: i=%i read_ready_fd=%i\n", i, fd); + if (FD_ISSET(fd, &read_fds)) { + result.read_fd_len += 1; + result.read_fds = (int *)realloc(result.read_fds, result.read_fd_len * sizeof(int)); + result.read_fds[result.read_fd_len - 1] = fd; + } + // ... which ones are erroring + if (FD_ISSET(fd, &err_fds)) { + result.error_fd_len += 1; + result.error_fds = (int *)realloc(result.error_fds, result.error_fd_len * sizeof(int)); + result.error_fds[result.error_fd_len - 1] = fd; + } + } + // ... and which ones are ready for writing + for (int i = 0; i < request.write_fd_len; i++) { + int fd = request.write_fds[i]; + // printf("bw_select: write_ready_fd=%i\n", fd); + if (FD_ISSET(fd, &write_fds)) { + result.write_fd_len += 1; + result.write_fds = (int *)realloc(result.write_fds, result.write_fd_len * sizeof(int)); + result.write_fds[result.write_fd_len - 1] = fd; + } + if (FD_ISSET(fd, &err_fds)) { + result.error_fd_len += 1; + result.error_fds = (int *)realloc(result.error_fds, result.error_fd_len * sizeof(int)); + result.error_fds[result.error_fd_len - 1] = fd; + } + } + return result; +} diff --git a/litewallet/src/Platform/BRSocketHelpers.h b/litewallet/src/Platform/BRSocketHelpers.h new file mode 100644 index 000000000..862df234a --- /dev/null +++ b/litewallet/src/Platform/BRSocketHelpers.h @@ -0,0 +1,52 @@ +// +// BRSocketHelpers.h +// BreadWallet +// +// Created by Samuel Sutch on 2/17/16. +// Copyright (c) 2016 breadwallet LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +#ifndef BRSocketHelpers_h +#define BRSocketHelpers_h + +#include + +int bw_nbioify(int fd); + +struct bw_select_request { + int write_fd_len; + int read_fd_len; + int *write_fds; + int *read_fds; +}; + +struct bw_select_result { + int error; // if > 0 there is an error + int write_fd_len; + int read_fd_len; + int error_fd_len; + int *write_fds; + int *read_fds; + int *error_fds; +}; + +struct bw_select_result bw_select(struct bw_select_request); + +#endif /* BRSocketHelpers_h */ diff --git a/litewallet/src/Platform/BRTar.swift b/litewallet/src/Platform/BRTar.swift new file mode 100644 index 000000000..027a6be5f --- /dev/null +++ b/litewallet/src/Platform/BRTar.swift @@ -0,0 +1,178 @@ +import Foundation + +enum BRTarError: Error { + case unknown + case fileDoesntExist +} + +enum BRTarType { + case file + case directory + case nullBlock + case headerBlock + case unsupported + case invalid + + init(fromData: Data) { + if fromData.count < 1 { + BRTar.log("invalid data") + self = .invalid + return + } + let byte = (fromData as NSData).bytes.bindMemory(to: CChar.self, capacity: fromData.count)[0] + switch byte { + case CChar(48): // "0" + self = .file + case CChar(53): // "5" + self = .directory + case CChar(0): + self = .nullBlock + case CChar(120): // "x" + self = .headerBlock + case CChar(49), CChar(50), CChar(51), CChar(52), CChar(53), CChar(54), CChar(55), CChar(103): + // "1, 2, 3, 4, 5, 6, 7, g" + self = .unsupported + default: + BRTar.log("invalid block type: \(byte)") + self = .invalid + } + } +} + +class BRTar { + static let tarBlockSize: UInt64 = 512 + static let tarTypePosition: UInt64 = 156 + static let tarNamePosition: UInt64 = 0 + static let tarNameSize: UInt64 = 100 + static let tarSizePosition: UInt64 = 124 + static let tarSizeSize: UInt64 = 12 + static let tarMaxBlockLoadInMemory: UInt64 = 100 + static let tarLogEnabled: Bool = false + + static func createFilesAndDirectoriesAtPath(_ path: String, withTarPath tarPath: String) throws { + let fm = FileManager.default + if !fm.fileExists(atPath: tarPath) { + log("tar file \(tarPath) does not exist") + throw BRTarError.fileDoesntExist + } + let attrs = try fm.attributesOfItem(atPath: tarPath) + guard let tarFh = FileHandle(forReadingAtPath: tarPath) + else { + log("could not open tar file for reading") + throw BRTarError.unknown + } + var loc: UInt64 = 0 + guard let sizee = attrs[FileAttributeKey.size] as? Int + else { + log("could not read tar file size") + throw BRTarError.unknown + } + let size = UInt64(sizee) + + while loc < size { + var blockCount: UInt64 = 1 + let tarType = readTypeAtLocation(loc, fromHandle: tarFh) + switch tarType { + case .file: + // read name + let name = try readNameAtLocation(loc, fromHandle: tarFh) + log("got file name from tar \(name)") + let newFilePath = (path as NSString).appendingPathComponent(name) + log("will write to \(newFilePath)") + var size = readSizeAtLocation(loc, fromHandle: tarFh) + log("its size is \(size)") + + if fm.fileExists(atPath: newFilePath) { + try fm.removeItem(atPath: newFilePath) + } + if size == 0 { + // empty file + try "".write(toFile: newFilePath, atomically: true, encoding: String.Encoding.utf8) + break + } + blockCount += (size - 1) / tarBlockSize + 1 + // write file + fm.createFile(atPath: newFilePath, contents: nil, attributes: nil) + guard let destFh = FileHandle(forWritingAtPath: newFilePath) + else { + log("unable to open destination file for writing") + throw BRTarError.unknown + } + tarFh.seek(toFileOffset: loc + tarBlockSize) + let maxSize = tarMaxBlockLoadInMemory * tarBlockSize + while size > maxSize { + autoreleasepool { () in + destFh.write(tarFh.readData(ofLength: Int(maxSize))) + size -= maxSize + } + } + destFh.write(tarFh.readData(ofLength: Int(size))) + destFh.closeFile() + log("success writing file") + case .directory: + let name = try readNameAtLocation(loc, fromHandle: tarFh) + log("got new directory name \(name)") + let dirPath = (path as NSString).appendingPathComponent(name) + log("will create directory at \(dirPath)") + + if fm.fileExists(atPath: dirPath) { + try fm.removeItem(atPath: dirPath) // will automatically recursively remove directories if exists + } + + try fm.createDirectory(atPath: dirPath, withIntermediateDirectories: true, attributes: nil) + log("success creating directory") + case .nullBlock: + break + case .headerBlock: + blockCount += 1 + case .unsupported: + let size = readSizeAtLocation(loc, fromHandle: tarFh) + blockCount += size / tarBlockSize + case .invalid: + log("Invalid block encountered") + throw BRTarError.unknown + } + loc += blockCount * tarBlockSize + log("new location \(loc)") + } + } + + fileprivate static func readTypeAtLocation(_ location: UInt64, fromHandle handle: FileHandle) -> BRTarType + { + log("reading type at location \(location)") + handle.seek(toFileOffset: location + tarTypePosition) + let typeDat = handle.readData(ofLength: 1) + let ret = BRTarType(fromData: typeDat) + log("type: \(ret)") + return ret + } + + fileprivate static func readNameAtLocation(_ location: UInt64, fromHandle handle: FileHandle) throws -> String + { + handle.seek(toFileOffset: location + tarNamePosition) + let dat = handle.readData(ofLength: Int(tarNameSize)) + guard let ret = String(bytes: dat, encoding: String.Encoding.ascii) + else { + log("unable to read name") + throw BRTarError.unknown + } + return ret + } + + fileprivate static func readSizeAtLocation(_ location: UInt64, fromHandle handle: FileHandle) -> UInt64 + { + handle.seek(toFileOffset: location + tarSizePosition) + let sizeDat = handle.readData(ofLength: Int(tarSizeSize)) + let octal = NSString(data: sizeDat, encoding: String.Encoding.ascii.rawValue)! + log("size octal: \(octal)") + let dec = strtoll(octal.utf8String, nil, 8) + log("size decimal: \(dec)") + return UInt64(dec) + } + + fileprivate static func log(_ string: String) { + if tarLogEnabled { + print("[BRTar] \(string)") + } + } +} diff --git a/litewallet/src/Platform/BRWebViewController.swift b/litewallet/src/Platform/BRWebViewController.swift new file mode 100644 index 000000000..19f419cba --- /dev/null +++ b/litewallet/src/Platform/BRWebViewController.swift @@ -0,0 +1,219 @@ +import Foundation +import UIKit +import WebKit + +@available(iOS 8.0, *) +@objc open class BRWebViewController: UIViewController, WKNavigationDelegate, BRWebSocketClient, WKScriptMessageHandler +{ + var wkProcessPool: WKProcessPool + var webView: WKWebView? + var server = BRHTTPServer() + var debugEndpoint: String? + var mountPoint: String + var walletManager: WalletManager + let store: Store + let noAuthApiClient: BRAPIClient? + let partner: String? + let activityIndicator: UIActivityIndicatorView + var didLoad = false + var didAppear = false + var didLoadTimeout = 2500 + var waitTimeout = 90 + // we are also a socket server which sends didview/didload events to the listening client(s) + var sockets = [String: BRWebSocket]() + + // this is the data that occasionally gets sent to the above connected sockets + var webViewInfo: [String: Any] { + return [ + "visible": didAppear, + "loaded": didLoad, + ] + } + + var indexUrl: URL { + return URL(string: "http://127.0.0.1:\(server.port)\(mountPoint)")! + } + + private let messageUIPresenter = MessageUIPresenter() + + init(partner: String?, mountPoint: String = "/", walletManager: WalletManager, store: Store, noAuthApiClient: BRAPIClient? = nil) + { + wkProcessPool = WKProcessPool() + self.mountPoint = mountPoint + self.walletManager = walletManager + self.store = store + self.noAuthApiClient = noAuthApiClient + self.partner = partner ?? "" + activityIndicator = UIActivityIndicatorView() + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + public required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override open var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + override open func loadView() { + didLoad = false + + let contentController = WKUserContentController() + contentController.add(self, name: "callback") + + let config = WKWebViewConfiguration() + config.processPool = wkProcessPool + config.allowsInlineMediaPlayback = false + config.allowsAirPlayForMediaPlayback = false + config.allowsPictureInPictureMediaPlayback = false + config.userContentController = contentController + + let request = URLRequest(url: indexUrl) + + view = UIView(frame: CGRect.zero) + view.backgroundColor = UIColor(red: 0.98, green: 0.98, blue: 0.98, alpha: 1.0) + + webView = WKWebView(frame: CGRect.zero, configuration: config) + webView?.navigationDelegate = self + webView?.backgroundColor = UIColor(red: 0.98, green: 0.98, blue: 0.98, alpha: 1.0) + _ = webView?.load(request) + webView?.autoresizingMask = [UIViewAutoresizing.flexibleHeight, UIViewAutoresizing.flexibleWidth] + webView?.scrollView.contentInsetAdjustmentBehavior = .never + + view.addSubview(webView!) + + let center = NotificationCenter.default + center.addObserver(forName: .UIApplicationDidBecomeActive, object: nil, queue: .main) + { [weak self] _ in + self?.didAppear = true + if let info = self?.webViewInfo { + self?.sendToAllSockets(data: info) + } + } + center.addObserver(forName: .UIApplicationWillResignActive, object: nil, queue: .main) + { [weak self] _ in + self?.didAppear = false + if let info = self?.webViewInfo { + self?.sendToAllSockets(data: info) + } + } + + activityIndicator.activityIndicatorViewStyle = .white + activityIndicator.color = .darkGray + activityIndicator.startAnimating() + activityIndicator.isHidden = false + activityIndicator.bounds = CGRect(x: 0, y: 0, width: 15, height: 15) + activityIndicator.autoresizingMask = [UIViewAutoresizing.flexibleHeight, UIViewAutoresizing.flexibleWidth] + view.addSubview(activityIndicator) + } + + override open func viewDidAppear(_: Bool) { + didAppear = true + sendToAllSockets(data: webViewInfo) + } + + override open func viewDidDisappear(_: Bool) { + didAppear = false + sendToAllSockets(data: webViewInfo) + } + + // signal to the presenter that the webview content successfully loaded + fileprivate func webviewDidLoad() { + didLoad = true + sendToAllSockets(data: webViewInfo) + } + + fileprivate func closeNow() { + store.trigger(name: .showStatusBar) + dismiss(animated: true, completion: nil) + } + + open func preload() { + _ = view // force webview loading + } + + open func refresh() { + let request = URLRequest(url: indexUrl) + _ = webView?.load(request) + } + + // MARK: - navigation delegate + + open func webView(_: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) + { + if let url = navigationAction.request.url?.absoluteString { + let mutableurl = url + if mutableurl.contains("/close") { + DispatchQueue.main.async { + self.closeNow() + } + } + } + return decisionHandler(.allow) + } + + public func webView(_: WKWebView, didFinish _: WKNavigation!) { + activityIndicator.stopAnimating() + activityIndicator.isHidden = true + } + + public func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) + { + guard let response = message.body as? String else { return } + + let URLString = URL(string: "https://checkout.simplexcc.com/payments/new") + + var req = URLRequest(url: URLString!) + req.httpBody = Data(response.utf8) + req.httpMethod = "POST" + + DispatchQueue.main.async { + let browser = BRBrowserViewController() + browser.load(req) + self.present(browser, animated: true, completion: nil) + } + } + + // MARK: - socket delegate + + func sendTo(socket: BRWebSocket, data: [String: Any]) { + do { + let j = try JSONSerialization.data(withJSONObject: data, options: []) + if let s = String(data: j, encoding: .utf8) { + socket.request.queue.async { + socket.send(s) + } + } + } catch let e { + print("LOCATION SOCKET FAILED ENCODE JSON: \(e)") + } + } + + func sendToAllSockets(data: [String: Any]) { + for (_, s) in sockets { + sendTo(socket: s, data: data) + } + } + + public func socketDidConnect(_ socket: BRWebSocket) { + print("WEBVIEW SOCKET CONNECT \(socket.id)") + sockets[socket.id] = socket + sendTo(socket: socket, data: webViewInfo) + } + + public func socketDidDisconnect(_ socket: BRWebSocket) { + print("WEBVIEW SOCKET DISCONNECT \(socket.id)") + sockets.removeValue(forKey: socket.id) + } + + public func socket(_: BRWebSocket, didReceiveText text: String) { + print("WEBVIEW SOCKET RECV TEXT \(text)") + } + + public func socket(_: BRWebSocket, didReceiveData data: Data) { + print("WEBVIEW SOCKET RECV TEXT \(data.hexString)") + } +} diff --git a/litewallet/src/Platform/Extensions.swift b/litewallet/src/Platform/Extensions.swift new file mode 100644 index 000000000..b79051f57 --- /dev/null +++ b/litewallet/src/Platform/Extensions.swift @@ -0,0 +1,566 @@ +import BRCore +import Foundation +// import libbz2 +import UIKit + +public extension String { + static func buildQueryString(_ options: [String: [String]]?, includeQ: Bool = false) -> String { + var s = "" + if let options = options, !options.isEmpty { + s = includeQ ? "?" : "" + var i = 0 + for (k, vals) in options { + for v in vals { + if i != 0 { + s += "&" + } + i += 1 + s += "\(k.urlEscapedString)=\(v.urlEscapedString)" + } + } + } + return s + } + + static var urlQuoteCharacterSet: CharacterSet { + if let cset = (NSMutableCharacterSet.urlQueryAllowed as NSCharacterSet).mutableCopy() as? NSMutableCharacterSet + { + cset.removeCharacters(in: "?=&") + return cset as CharacterSet + } + return NSMutableCharacterSet.urlQueryAllowed as CharacterSet + } + + func md5() -> String { + guard let data = data(using: .utf8) + else { + assertionFailure("couldnt encode string as utf8 data") + return "" + } + + var result = Data(count: 128 / 8) + let resultCount = result.count + return result.withUnsafeMutableBytes + { (resultBytes: UnsafeMutablePointer) -> String in + data.withUnsafeBytes { dataBytes in + BRMD5(resultBytes, dataBytes, data.count) + } + var hash = String() + for i in 0 ..< resultCount { + hash = hash.appendingFormat("%02x", resultBytes[i]) + } + return hash + } + } + + func base58DecodedData() -> Data { + let len = BRBase58Decode(nil, 0, self) + var data = Data(count: len) + _ = data.withUnsafeMutableBytes { BRBase58Decode($0, len, self) } + return data + } + + var urlEscapedString: String { + return addingPercentEncoding(withAllowedCharacters: String.urlQuoteCharacterSet) ?? "" + } + + func parseQueryString() -> [String: [String]] { + var ret = [String: [String]]() + var strippedString = self + if String(self[...size) + } else if Int32(i) <= UINT16_MAX { + var header = UInt8(VAR_INT16_HEADER) + var payload = CFSwapInt16HostToLittle(UInt16(i)) + append(&header, length: MemoryLayout.size) + append(&payload, length: MemoryLayout.size) + } else if UInt32(i) <= UINT32_MAX { + var header = UInt8(VAR_INT32_HEADER) + var payload = CFSwapInt32HostToLittle(UInt32(i)) + append(&header, length: MemoryLayout.size) + append(&payload, length: MemoryLayout.size) + } else { + var header = UInt8(VAR_INT64_HEADER) + var payload = CFSwapInt64HostToLittle(i) + append(&header, length: MemoryLayout.size) + append(&payload, length: MemoryLayout.size) + } + } +} + +var BZCompressionBufferSize: UInt32 = 1024 +var BZDefaultBlockSize: Int32 = 7 +var BZDefaultWorkFactor: Int32 = 100 + +private struct AssociatedKeys { + static var hexString = "hexString" +} + +public extension Data { + var hexString: String { + if let string = getCachedHexString() { + return string + } else { + let string = reduce("") { $0 + String(format: "%02x", $1) } + setHexString(string: string) + return string + } + } + + private func setHexString(string: String) { + objc_setAssociatedObject(self, &AssociatedKeys.hexString, string, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + private func getCachedHexString() -> String? { + return objc_getAssociatedObject(self, &AssociatedKeys.hexString) as? String + } + +// var bzCompressedData: Data? { +// guard !isEmpty +// else { +// return self +// } +// +// var compressed = Data() +// var stream = bz_stream() +// var mself = self +// var success = true +// mself.withUnsafeMutableBytes { (selfBuff: UnsafeMutablePointer) in +// let outBuff = UnsafeMutablePointer.allocate(capacity: Int(BZCompressionBufferSize)) +// defer { outBuff.deallocate() } +// +// stream.next_in = selfBuff +// stream.avail_in = UInt32(self.count) +// stream.next_out = outBuff +// stream.avail_out = BZCompressionBufferSize +// +// var bzret = BZ2_bzCompressInit(&stream, BZDefaultBlockSize, 0, BZDefaultWorkFactor) +// guard bzret == BZ_OK +// else { +// print("failed compression init") +// success = false +// return +// } +// repeat { +// bzret = BZ2_bzCompress(&stream, stream.avail_in > 0 ? BZ_RUN : BZ_FINISH) +// guard bzret >= BZ_OK +// else { +// print("failed compress") +// success = false +// return +// } +// let bpp = UnsafeBufferPointer(start: outBuff, count: Int(BZCompressionBufferSize) - Int(stream.avail_out)) +// compressed.append(bpp) +// stream.next_out = outBuff +// stream.avail_out = BZCompressionBufferSize +// } while bzret != BZ_STREAM_END +// } +// BZ2_bzCompressEnd(&stream) +// guard success else { return nil } +// return compressed +// } +// +// init?(bzCompressedData data: Data) { +// guard !data.isEmpty +// else { +// return nil +// } +// var stream = bz_stream() +// var decompressed = Data() +// var myDat = data +// var success = true +// myDat.withUnsafeMutableBytes { (datBuff: UnsafeMutablePointer) in +// let outBuff = UnsafeMutablePointer.allocate(capacity: Int(BZCompressionBufferSize)) +// defer { outBuff.deallocate() } +// +// stream.next_in = datBuff +// stream.avail_in = UInt32(data.count) +// stream.next_out = outBuff +// stream.avail_out = BZCompressionBufferSize +// +// var bzret = BZ2_bzDecompressInit(&stream, 0, 0) +// guard bzret == BZ_OK +// else { +// print("failed decompress init") +// success = false +// return +// } +// repeat { +// bzret = BZ2_bzDecompress(&stream) +// guard bzret >= BZ_OK +// else { +// print("failed decompress") +// success = false +// return +// } +// let bpp = UnsafeBufferPointer(start: outBuff, count: Int(BZCompressionBufferSize) - Int(stream.avail_out)) +// decompressed.append(bpp) +// stream.next_out = outBuff +// stream.avail_out = BZCompressionBufferSize +// } while bzret != BZ_STREAM_END +// } +// BZ2_bzDecompressEnd(&stream) +// guard success else { return nil } +// self.init(decompressed) +// } + + var base58: String { + return withUnsafeBytes { (selfBytes: UnsafePointer) -> String in + let len = BRBase58Encode(nil, 0, selfBytes, self.count) + var data = Data(count: len) + return data.withUnsafeMutableBytes { (b: UnsafeMutablePointer) in + BRBase58Encode(b, len, selfBytes, self.count) + return String(cString: b) + } + } + } + + var sha1: Data { + var data = Data(count: 20) + data.withUnsafeMutableBytes { (bytes: UnsafeMutablePointer) in + self.withUnsafeBytes { (selfBytes: UnsafePointer) in + BRSHA1(bytes, selfBytes, self.count) + } + } + return data + } + + var sha256: Data { + var data = Data(count: 32) + data.withUnsafeMutableBytes { (bytes: UnsafeMutablePointer) in + self.withUnsafeBytes { (selfBytes: UnsafePointer) in + BRSHA256(bytes, selfBytes, self.count) + } + } + return data + } + + var sha256_2: Data { + return sha256.sha256 + } + + var uInt256: UInt256 { + return withUnsafeBytes { (ptr: UnsafePointer) -> UInt256 in + ptr.pointee + } + } + + func uInt8(atOffset offset: UInt) -> UInt8 { + let offt = Int(offset) + let size = MemoryLayout.size + if count < offt + size { return 0 } + return subdata(in: offt ..< (offt + size)).withUnsafeBytes + { (ptr: UnsafePointer) -> UInt8 in + ptr.pointee + } + } + + func uInt32(atOffset offset: UInt) -> UInt32 { + let offt = Int(offset) + let size = MemoryLayout.size + if count < offt + size { return 0 } + return subdata(in: offt ..< (offt + size)).withUnsafeBytes + { (ptr: UnsafePointer) -> UInt32 in + CFSwapInt32LittleToHost(ptr.pointee) + } + } + + func uInt64(atOffset offset: UInt) -> UInt64 { + let offt = Int(offset) + let size = MemoryLayout.size + if count < offt + size { return 0 } + return subdata(in: offt ..< (offt + size)).withUnsafeBytes + { (ptr: UnsafePointer) -> UInt64 in + CFSwapInt64LittleToHost(ptr.pointee) + } + } + + func compactSign(key: BRKey) -> Data { + return withUnsafeBytes { (_: UnsafePointer) -> Data in + var data = Data(count: 65) + var k = key + _ = data.withUnsafeMutableBytes { BRKeyCompactSign(&k, $0, 65, self.uInt256) } + return data + } + } + + private func genNonce() -> [UInt8] { + var tv = timeval() + gettimeofday(&tv, nil) + var t = UInt64(tv.tv_usec) * 1_000_000 + UInt64(tv.tv_usec) + let p = [UInt8](repeating: 0, count: 4) + return Data(bytes: &t, count: MemoryLayout.size).withUnsafeBytes + { (dat: UnsafePointer) -> [UInt8] in + let buf = UnsafeBufferPointer(start: dat, count: MemoryLayout.size) + return p + Array(buf) + } + } + + func chacha20Poly1305AEADEncrypt(key: BRKey) -> Data { + let data = [UInt8](self) + let inData = UnsafePointer(data) + let nonce = genNonce() + var null = CChar(0) + var sk = key.secret + return withUnsafePointer(to: &sk) { + let outSize = BRChacha20Poly1305AEADEncrypt(nil, 0, $0, nonce, inData, data.count, &null, 0) + var outData = [UInt8](repeating: 0, count: outSize) + BRChacha20Poly1305AEADEncrypt(&outData, outSize, $0, nonce, inData, data.count, &null, 0) + return Data(nonce + outData) + } + } + + func chacha20Poly1305AEADDecrypt(key: BRKey) throws -> Data { + let data = [UInt8](self) + guard data.count > 12 else { throw BRReplicatedKVStoreError.malformedData } + let nonce = Array(data[data.startIndex ... data.startIndex.advanced(by: 12)]) + let inData = Array(data[data.startIndex.advanced(by: 12) ... (data.endIndex - 1)]) + var null = CChar(0) + var sk = key.secret + return withUnsafePointer(to: &sk) { + let outSize = BRChacha20Poly1305AEADDecrypt(nil, 0, $0, nonce, inData, inData.count, &null, 0) + var outData = [UInt8](repeating: 0, count: outSize) + BRChacha20Poly1305AEADDecrypt(&outData, outSize, $0, nonce, inData, inData.count, &null, 0) + return Data(outData) + } + } + + var masterPubKey: BRMasterPubKey? { + guard count >= (4 + 32 + 33) else { return nil } + var mpk = BRMasterPubKey() + mpk.fingerPrint = subdata(in: 0 ..< 4).withUnsafeBytes { $0.pointee } + mpk.chainCode = subdata(in: 4 ..< (4 + 32)).withUnsafeBytes { $0.pointee } + mpk.pubKey = subdata(in: (4 + 32) ..< (4 + 32 + 33)).withUnsafeBytes { $0.pointee } + return mpk + } + + init(masterPubKey mpk: BRMasterPubKey) { + var data = [mpk.fingerPrint].withUnsafeBufferPointer { Data(buffer: $0) } + [mpk.chainCode].withUnsafeBufferPointer { data.append($0) } + [mpk.pubKey].withUnsafeBufferPointer { data.append($0) } + self.init(data) + } + + var urlEncodedObject: [String: [String]]? { + guard let str = String(data: self, encoding: .utf8) + else { + return nil + } + return str.parseQueryString() + } +} + +public extension Date { + static func withMsTimestamp(_ ms: UInt64) -> Date { + return Date(timeIntervalSince1970: Double(ms) / 1000.0) + } + + func msTimestamp() -> UInt64 { + return UInt64((timeIntervalSince1970 < 0 ? 0 : timeIntervalSince1970) * 1000.0) + } + + // this is lifted from: https://github.com/Fykec/NSDate-RFC1123/blob/master/NSDate%2BRFC1123.swift + // Copyright © 2015 Foster Yin. All rights reserved. + fileprivate static func cachedThreadLocalObjectWithKey(_ key: String, create: () -> T) -> T + { + let threadDictionary = Thread.current.threadDictionary + if let cachedObject = threadDictionary[key] as! T? { + return cachedObject + } else { + let newObject = create() + threadDictionary[key] = newObject + return newObject + } + } + + fileprivate static func RFC1123DateFormatter() -> DateFormatter { + return cachedThreadLocalObjectWithKey("RFC1123DateFormatter") { + let locale = Locale(identifier: "en_US") + let timeZone = TimeZone(identifier: "GMT") + let dateFormatter = DateFormatter() + dateFormatter.locale = locale // need locale for some iOS 9 verision, will not select correct default locale + dateFormatter.timeZone = timeZone + dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss z" + return dateFormatter + } + } + + fileprivate static func RFC850DateFormatter() -> DateFormatter { + return cachedThreadLocalObjectWithKey("RFC850DateFormatter") { + let locale = Locale(identifier: "en_US") + let timeZone = TimeZone(identifier: "GMT") + let dateFormatter = DateFormatter() + dateFormatter.locale = locale // need locale for some iOS 9 verision, will not select correct default locale + dateFormatter.timeZone = timeZone + dateFormatter.dateFormat = "EEEE, dd-MMM-yy HH:mm:ss z" + return dateFormatter + } + } + + fileprivate static func asctimeDateFormatter() -> DateFormatter { + return cachedThreadLocalObjectWithKey("asctimeDateFormatter") { + let locale = Locale(identifier: "en_US") + let timeZone = TimeZone(identifier: "GMT") + let dateFormatter = DateFormatter() + dateFormatter.locale = locale // need locale for some iOS 9 verision, will not select correct default locale + dateFormatter.timeZone = timeZone + dateFormatter.dateFormat = "EEE MMM d HH:mm:ss yyyy" + return dateFormatter + } + } + + static func fromRFC1123(_ dateString: String) -> Date? { + var date: Date? + // RFC1123 + date = Date.RFC1123DateFormatter().date(from: dateString) + if date != nil { + return date + } + + // RFC850 + date = Date.RFC850DateFormatter().date(from: dateString) + if date != nil { + return date + } + + // asctime-date + date = Date.asctimeDateFormatter().date(from: dateString) + if date != nil { + return date + } + return nil + } + + func RFC1123String() -> String? { + return Date.RFC1123DateFormatter().string(from: self) + } +} + +public extension BRKey { + var publicKey: Data { + var k = self + let len = BRKeyPubKey(&k, nil, 0) + var data = Data(count: len) + BRKeyPubKey(&k, data.withUnsafeMutableBytes { (d: UnsafeMutablePointer) -> UnsafeMutablePointer in d }, len) + return data + } +} + +extension UIImage { + /// Represents a scaling mode + enum ScalingMode { + case aspectFill + case aspectFit + + /// Calculates the aspect ratio between two sizes + /// + /// - parameters: + /// - size: the first size used to calculate the ratio + /// - otherSize: the second size used to calculate the ratio + /// + /// - return: the aspect ratio between the two sizes + func aspectRatio(between size: CGSize, and otherSize: CGSize) -> CGFloat { + let aspectWidth = size.width / otherSize.width + let aspectHeight = size.height / otherSize.height + + switch self { + case .aspectFill: + return max(aspectWidth, aspectHeight) + case .aspectFit: + return min(aspectWidth, aspectHeight) + } + } + } + + /// Scales an image to fit within a bounds with a size governed by the passed size. Also keeps the aspect ratio. + /// - parameters: + /// - newSize: the size of the bounds the image must fit within. + /// - scalingMode: the desired scaling mode + /// + /// - returns: a new scaled image. + func scaled(to newSize: CGSize, scalingMode: UIImage.ScalingMode = .aspectFill) -> UIImage { + let aspectRatio = scalingMode.aspectRatio(between: newSize, and: size) + + /* Build the rectangle representing the area to be drawn */ + var scaledImageRect = CGRect.zero + + scaledImageRect.size.width = size.width * aspectRatio + scaledImageRect.size.height = size.height * aspectRatio + scaledImageRect.origin.x = (newSize.width - size.width * aspectRatio) / 2.0 + scaledImageRect.origin.y = (newSize.height - size.height * aspectRatio) / 2.0 + + /* Draw and retrieve the scaled image */ + UIGraphicsBeginImageContext(newSize) + + draw(in: scaledImageRect) + let scaledImage = UIGraphicsGetImageFromCurrentImageContext() + + UIGraphicsEndImageContext() + + return scaledImage! + } +} + +extension Dictionary where Key: ExpressibleByStringLiteral, Value: Any { + var flattened: [Key: String] { + var ret = [Key: String]() + for (k, v) in self { + if let v = v as? [String] { + if !v.isEmpty { + ret[k] = v[0] + } + } + } + return ret + } + + var jsonString: String { + guard let json = try? JSONSerialization.data(withJSONObject: self, options: []) + else { + return "null" + } + guard let jstring = String(data: json, encoding: .utf8) + else { + return "null" + } + return jstring + } +} diff --git a/litewallet/src/Platform/TxMetaData.swift b/litewallet/src/Platform/TxMetaData.swift new file mode 100644 index 000000000..013289982 --- /dev/null +++ b/litewallet/src/Platform/TxMetaData.swift @@ -0,0 +1,134 @@ +import BRCore +import Foundation + +// MARK: - Txn Metadata + +// Txn metadata stores additional information about a given transaction +open class TxMetaData: BRKVStoreObject, BRCoding { + var classVersion: Int = 2 + + var blockHeight: Int = 0 + var exchangeRate: Double = 0 + var exchangeRateCurrency: String = "" + var feeRate: Double = 0 + var size: Int = 0 + var created: Date = .zeroValue() + var deviceId: String = "" + var comment = "" + + public required init?(coder decoder: BRCoder) { + classVersion = decoder.decode("classVersion") + if classVersion == Int.zeroValue() { + print("[BRTxMetadataObject] Unable to unarchive _TXMetadata: no version") + return nil + } + blockHeight = decoder.decode("bh") + exchangeRate = decoder.decode("er") + exchangeRateCurrency = decoder.decode("erc") + feeRate = decoder.decode("fr") + size = decoder.decode("s") + deviceId = decoder.decode("dId") + created = decoder.decode("c") + comment = decoder.decode("comment") + super.init(key: "", version: 0, lastModified: Date(), deleted: true, data: Data()) + } + + func encode(_ coder: BRCoder) { + coder.encode(classVersion, key: "classVersion") + coder.encode(blockHeight, key: "bh") + coder.encode(exchangeRate, key: "er") + coder.encode(exchangeRateCurrency, key: "erc") + coder.encode(feeRate, key: "fr") + coder.encode(size, key: "s") + coder.encode(created, key: "c") + coder.encode(deviceId, key: "dId") + coder.encode(comment, key: "comment") + } + + // Find metadata object based on the txHash + public init?(txHash: UInt256, store: BRReplicatedKVStore) { + var ver: UInt64 + var date: Date + var del: Bool + var bytes: [UInt8] + + print("[BRTxMetadataObject] find \(txHash.txKey)") + do { + (ver, date, del, bytes) = try store.get(txHash.txKey) + let bytesDat = Data(bytes: &bytes, count: bytes.count) + super.init(key: txHash.txKey, version: ver, lastModified: date, deleted: del, data: bytesDat) + return + } catch let e { + print("[BRTxMetadataObject] Unable to initialize BRTxMetadataObject: \(String(describing: e))") + } + + return nil + } + + // Find metadata object based on the txKey + public init?(txKey: String, store: BRReplicatedKVStore) { + var ver: UInt64 + var date: Date + var del: Bool + var bytes: [UInt8] + + print("[BRTxMetadataObject] find \(txKey)") + do { + (ver, date, del, bytes) = try store.get(txKey) + let bytesDat = Data(bytes: &bytes, count: bytes.count) + super.init(key: txKey, version: ver, lastModified: date, deleted: del, data: bytesDat) + return + } catch let e { + print("[BRTxMetadataObject] Unable to initialize BRTxMetadataObject: \(String(describing: e))") + } + + return nil + } + + /// Create new transaction metadata + public init(transaction: BRTransaction, exchangeRate: Double, exchangeRateCurrency: String, feeRate: Double, + deviceId: String, comment: String? = nil) + { + print("[BRTxMetadataObject] new \(transaction.txHash.txKey)") + super.init(key: transaction.txHash.txKey, version: 0, lastModified: Date(), deleted: false, data: Data()) + blockHeight = Int(transaction.blockHeight) + created = Date() + var txn = transaction + size = BRTransactionSize(&txn) + self.exchangeRate = exchangeRate + self.exchangeRateCurrency = exchangeRateCurrency + self.feeRate = feeRate + self.deviceId = deviceId + self.comment = comment ?? "" + } + + override func getData() -> Data? { + return BRKeyedArchiver.archivedDataWithRootObject(self) + } + + override func dataWasSet(_ value: Data) { + guard let s: TxMetaData = BRKeyedUnarchiver.unarchiveObjectWithData(value) + else { + print("[BRTxMetadataObject] unable to deserialise tx metadata") + return + } + blockHeight = s.blockHeight + exchangeRate = s.exchangeRate + exchangeRateCurrency = s.exchangeRateCurrency + feeRate = s.feeRate + size = s.size + created = s.created + deviceId = s.deviceId + comment = s.comment + } +} + +extension UInt256 { + var txKey: String { + var u = self + return withUnsafePointer(to: &u) { p in + let bd = Data(bytes: p, count: MemoryLayout.stride).sha256 + return "txn2-\(bd.hexString)" + } + } +} diff --git a/litewallet/src/Platform/WalletInfo.swift b/litewallet/src/Platform/WalletInfo.swift new file mode 100644 index 000000000..05d655ce4 --- /dev/null +++ b/litewallet/src/Platform/WalletInfo.swift @@ -0,0 +1,54 @@ +import Foundation + +let walletInfoKey = "wallet-info" + +class WalletInfo: BRKVStoreObject, BRCoding { + var classVersion = 2 + var name = "" + var creationDate = Date.zeroValue() + + // Create new + init(name: String) { + super.init(key: walletInfoKey, version: 0, lastModified: Date(), deleted: false, data: Data()) + self.name = name + } + + // Find existing + init?(kvStore: BRReplicatedKVStore) { + var ver: UInt64 + var date: Date + var del: Bool + var bytes: [UInt8] + do { + (ver, date, del, bytes) = try kvStore.get(walletInfoKey) + } catch let e { + print("Unable to initialize WalletInfo: \(e)") + return nil + } + let bytesData = Data(bytes: &bytes, count: bytes.count) + super.init(key: walletInfoKey, version: ver, lastModified: date, deleted: del, data: bytesData) + } + + override func getData() -> Data? { + return BRKeyedArchiver.archivedDataWithRootObject(self) + } + + override func dataWasSet(_ value: Data) { + guard let s: WalletInfo = BRKeyedUnarchiver.unarchiveObjectWithData(value) else { return } + name = s.name + creationDate = s.creationDate + } + + public required init?(coder decoder: BRCoder) { + classVersion = decoder.decode("classVersion") + name = decoder.decode("name") + creationDate = decoder.decode("creationDate") + super.init(key: "", version: 0, lastModified: Date(), deleted: true, data: Data()) + } + + func encode(_ coder: BRCoder) { + coder.encode(classVersion, key: "classVersion") + coder.encode(name, key: "name") + coder.encode(creationDate, key: "creationDate") + } +} diff --git a/litewallet/src/Platform/module.modulemap b/litewallet/src/Platform/module.modulemap new file mode 100644 index 000000000..836fb658f --- /dev/null +++ b/litewallet/src/Platform/module.modulemap @@ -0,0 +1,4 @@ +module BRSocketHelpers [system] [extern_c] { + header "BRSocketHelpers.h" + export * +} diff --git a/litewallet/src/ReachabilityMonitor.swift b/litewallet/src/ReachabilityMonitor.swift new file mode 100644 index 000000000..7e0ede5cd --- /dev/null +++ b/litewallet/src/ReachabilityMonitor.swift @@ -0,0 +1,50 @@ +import Foundation +import SystemConfiguration + +private func callback(reachability _: SCNetworkReachability, flags _: SCNetworkReachabilityFlags, info: UnsafeMutableRawPointer?) +{ + guard let info = info else { return } + let reachability = Unmanaged.fromOpaque(info).takeUnretainedValue() + reachability.notify() +} + +class ReachabilityMonitor: Trackable { + init() { + networkReachability = SCNetworkReachabilityCreateWithName(kCFAllocatorDefault, "google.com") + start() + } + + var didChange: ((Bool) -> Void)? + + private var networkReachability: SCNetworkReachability? + private let reachabilitySerialQueue = DispatchQueue(label: "com.litecoin.reachabilityQueue") + + func notify() { + DispatchQueue.main.async { + self.didChange?(self.isReachable) + self.saveEvent(self.isReachable ? "reachability.isReachable" : "reachability.isNotReachable") + } + } + + var isReachable: Bool { + return flags.contains(.reachable) + } + + private func start() { + var context = SCNetworkReachabilityContext() + context.info = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + guard let reachability = networkReachability else { return } + SCNetworkReachabilitySetCallback(reachability, callback, &context) + SCNetworkReachabilitySetDispatchQueue(reachability, reachabilitySerialQueue) + } + + private var flags: SCNetworkReachabilityFlags { + var flags = SCNetworkReachabilityFlags(rawValue: 0) + if let reachability = networkReachability, withUnsafeMutablePointer(to: &flags, { SCNetworkReachabilityGetFlags(reachability, UnsafeMutablePointer($0)) }) == true + { + return flags + } else { + return [] + } + } +} diff --git a/litewallet/src/RetryTimer.swift b/litewallet/src/RetryTimer.swift new file mode 100644 index 000000000..f92008d6c --- /dev/null +++ b/litewallet/src/RetryTimer.swift @@ -0,0 +1,25 @@ +import Foundation + +class RetryTimer { + var callback: (() -> Void)? + private var timer: Timer? + private var fibA: TimeInterval = 0.0 + private var fibB: TimeInterval = 1.0 + + func start() { + timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(retry), userInfo: nil, repeats: false) + } + + func stop() { + timer?.invalidate() + } + + @objc private func retry() { + callback?() + timer?.invalidate() + let newInterval = fibA + fibB + fibA = fibB + fibB = newInterval + timer = Timer.scheduledTimer(timeInterval: newInterval, target: self, selector: #selector(retry), userInfo: nil, repeats: false) + } +} diff --git a/litewallet/src/ScanViewController.swift b/litewallet/src/ScanViewController.swift new file mode 100644 index 000000000..fecc4ab44 --- /dev/null +++ b/litewallet/src/ScanViewController.swift @@ -0,0 +1 @@ +import Foundation diff --git a/litewallet/src/Sender.swift b/litewallet/src/Sender.swift new file mode 100644 index 000000000..62b0d11ca --- /dev/null +++ b/litewallet/src/Sender.swift @@ -0,0 +1,224 @@ +import BRCore +import Foundation +import UIKit + +enum SendResult { + case success + case creationError(String) + case publishFailure(BRPeerManagerError) +} + +class Sender { + // MARK: - Private Variables + + private let walletManager: WalletManager + + private let kvStore: BRReplicatedKVStore + + private let store: Store + + // MARK: - Public Variables + + var transaction: BRTxRef? + + var rate: Rate? + + var comment: String? + + var feePerKb: UInt64? + + var fee: UInt64 { + guard let tx = transaction else { return 0 } + return walletManager.wallet?.feeForTx(tx) ?? 0 + } + + var canUseBiometrics: Bool { + guard let tx = transaction else { return false } + return walletManager.canUseBiometrics(forTx: tx) + } + + init(walletManager: WalletManager, kvStore: BRReplicatedKVStore, store: Store) { + self.walletManager = walletManager + self.kvStore = kvStore + self.store = store + } + + func createTransaction(amount: UInt64, to: String) -> Bool { + transaction = walletManager.wallet?.createTransaction(forAmount: amount, toAddress: to) + return transaction != nil + } + + func createTransactionWithOpsOutputs(amount: UInt64, + to: String) -> Bool + { + transaction = walletManager.wallet?.createOpsTransaction(forAmount: amount, + toAddress: to, + opsFee: tieredOpsFee(amount: amount), + opsAddress: Partner.partnerKeyPath(name: .litewalletOps)) + + return transaction != nil + } + + func feeForTx(amount: UInt64) -> UInt64 { + return walletManager.wallet?.feeForTx(amount: amount) ?? 0 + } + + /// Send + /// - Parameters: + /// - biometricsMessage: Response from decoding the biometrics + /// - rate: LTC - Fiat rate + /// - comment: Users note to themselves + /// - feePerKb: comment rate of fee per kb + /// - verifyPinFunction: verification + /// - completion: completion + func send(biometricsMessage: String, + rate: Rate?, + comment: String?, + feePerKb: UInt64, + verifyPinFunction: + @escaping (@escaping (String) -> Bool) -> Void, + completion: @escaping (SendResult) -> Void) + { + guard let tx = transaction + else { + return completion(.creationError(S.Send.createTransactionError.localize())) + } + + self.rate = rate + self.comment = comment + self.feePerKb = feePerKb + + if UserDefaults.isBiometricsEnabled && + walletManager.canUseBiometrics(forTx: tx) + { + DispatchQueue.walletQueue.async { [weak self] in + guard let myself = self else { return } + myself + .walletManager + .signTransaction(tx, + biometricsPrompt: + biometricsMessage, + completion: { result in + if result == .success { + myself.publish(completion: completion) + } else { + if result == .failure || result == .fallback { + myself.verifyPin(tx: tx, + withFunction: verifyPinFunction, + completion: completion) + } + } + }) + } + } else { + verifyPin(tx: tx, withFunction: verifyPinFunction, completion: completion) + } + } + + /// Verify Pin + /// - Parameters: + /// - tx: TX package + /// - withFunction: completion mid-range + /// - completion: completion + + // DEV: Important Note + // This func needs to be REFACTORED as it violates OOP and intertangles TX and Pin authentication + // This means it should be 2 functions. + // VerifyPIN and VerifyTX + private func verifyPin(tx: BRTxRef, + withFunction: (@escaping (String) -> Bool) -> Void, + completion: @escaping (SendResult) -> Void) + { + withFunction { pin in + var success = false + let group = DispatchGroup() + group.enter() + DispatchQueue.walletQueue.async { + if self.walletManager.signTransaction(tx, pin: pin) { + self.publish(completion: completion) + success = true + } + group.leave() + } + let result = group.wait(timeout: .now() + 30.0) + if result == .timedOut { + let properties: [String: String] = + ["ERROR_TX": "\(tx.txHash)", + "ERROR_BLOCKHEIGHT": "\(tx.blockHeight)"] + + LWAnalytics.logEventWithParameters(itemName: + ._20200112_ERR, + properties: properties) + + let alert = UIAlertController(title: S.LitewalletAlert.corruptionError.localize(), + message: S.LitewalletAlert.corruptionMessage.localize(), + preferredStyle: .alert) + + UserDefaults.didSeeCorruption = true + alert.addAction(UIAlertAction(title: "OK", + style: .default, + handler: nil)) + return false + } + return success + } + } + + /// Publish TX + /// - Parameter completion: completion + private func publish(completion: @escaping (SendResult) -> Void) { + guard let tx = transaction else { assertionFailure("publish failure"); return } + DispatchQueue.walletQueue.async { [weak self] in + guard let myself = self else { assertionFailure("myself didn't exist"); return } + myself.walletManager.peerManager?.publishTx(tx, completion: { _, error in + DispatchQueue.main.async { + if let error = error { + completion(.publishFailure(error)) + } else { + myself.setMetaData() + completion(.success) + } + } + }) + } + } + + /// Set transaction metadata + private func setMetaData() { + // Fires an event if the rate is not set + guard let rate = rate + else { + LWAnalytics.logEventWithParameters(itemName: ._20200111_RNI) + return + } + + // Fires an event if the transaction is not set + guard let tx = transaction + else { + LWAnalytics.logEventWithParameters(itemName: ._20200111_TNI) + return + } + + // Fires an event if the feePerKb is not set + guard let feePerKb = feePerKb + else { + LWAnalytics.logEventWithParameters(itemName: ._20200111_FNI) + return + } + + let metaData = TxMetaData(transaction: tx.pointee, + exchangeRate: rate.rate, + exchangeRateCurrency: rate.code, + feeRate: Double(feePerKb), + deviceId: UserDefaults.standard.deviceID, + comment: comment) + do { + _ = try kvStore.set(metaData) + } catch { + LWAnalytics.logEventWithParameters(itemName: ._20200112_ERR, + properties: ["error": + String(describing: error)]) + } + store.trigger(name: .txMemoUpdated(tx.pointee.txHash.description)) + } +} diff --git a/litewallet/src/SignupWebView.swift b/litewallet/src/SignupWebView.swift new file mode 100644 index 000000000..1f5163d31 --- /dev/null +++ b/litewallet/src/SignupWebView.swift @@ -0,0 +1,83 @@ +import Combine +import Foundation +import SafariServices +import SwiftUI +import UIKit +import WebKit + +struct SignupWebView: UIViewRepresentable { + @Binding + var userAction: Bool + + let urlString: String + + private var webView: WKWebView? + + init(userAction: Binding, urlString: String) { + webView = WKWebView() + self.urlString = urlString + _userAction = userAction + } + + func makeUIView(context: Context) -> WKWebView { + let source = "var meta = document.createElement('meta');" + + "meta.name = 'viewport';" + + "meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no';" + + "var head = document.getElementsByTagName('head')[0];" + + "head.appendChild(meta);" + + let script = WKUserScript(source: source, + injectionTime: .atDocumentEnd, + forMainFrameOnly: true) + + let userContentController = WKUserContentController() + userContentController.addUserScript(script) + + let configuration = WKWebViewConfiguration() + configuration.userContentController = userContentController + + let _wkwebview = WKWebView(frame: .zero, configuration: configuration) + _wkwebview.navigationDelegate = context.coordinator + _wkwebview.uiDelegate = context.coordinator + _wkwebview.allowsBackForwardNavigationGestures = false + _wkwebview.scrollView.isScrollEnabled = false + _wkwebview.backgroundColor = UIColor.liteWalletDarkBlue + _wkwebview.load(URLRequest(url: URL(string: urlString)!)) + + return _wkwebview + } + + func updateUIView(_ webView: WKWebView, context _: Context) { + webView.evaluateJavaScript("document.getElementById('submit-email').value") { response, _ in + + if let resultString = response as? String, + resultString.contains("Please") + { + userAction = true + + let signupDict: [String: String] = ["date_accepted": Date().ISO8601Format()] + LWAnalytics.logEventWithParameters(itemName: ._20240101_US, properties: signupDict) + } + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(self) + } + + class Coordinator: NSObject, + WKNavigationDelegate, + WKUIDelegate, + WKScriptMessageHandler + { + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + print("::: message \(message)") + } + + let parent: SignupWebView + + init(_ parent: SignupWebView) { + self.parent = parent + } + } +} diff --git a/litewallet/src/SignupWebViewModel.swift b/litewallet/src/SignupWebViewModel.swift new file mode 100644 index 000000000..d4de24f21 --- /dev/null +++ b/litewallet/src/SignupWebViewModel.swift @@ -0,0 +1,14 @@ +// +// SignupWebViewModel.swift +// litewallet +// +// Created by Kerry Washington on 1/9/24. +// Copyright © 2024 Litecoin Foundation. All rights reserved. +// +import Combine +import Foundation + +class SignupWebViewModel: ObservableObject { + var showLoader = PassthroughSubject() + var valuePublisher = PassthroughSubject() +} diff --git a/litewallet/src/SimpleRedux.swift b/litewallet/src/SimpleRedux.swift new file mode 100644 index 000000000..1482ceda4 --- /dev/null +++ b/litewallet/src/SimpleRedux.swift @@ -0,0 +1,186 @@ +import UIKit + +typealias Reducer = (ReduxState) -> ReduxState +typealias Selector = (_ oldState: ReduxState, _ newState: ReduxState) -> Bool + +protocol Action { + var reduce: Reducer { get } +} + +// We need reference semantics for Subscribers, so they are restricted to classes +protocol Subscriber: AnyObject {} + +extension Subscriber { + var hashValue: Int { + return ObjectIdentifier(self).hashValue + } +} + +typealias StateUpdatedCallback = (ReduxState) -> Void + +struct Subscription { + let selector: (_ oldState: ReduxState, _ newState: ReduxState) -> Bool + let callback: (ReduxState) -> Void +} + +struct Trigger { + let name: TriggerName + let callback: (TriggerName?) -> Void +} + +enum TriggerName { + case presentFaq(String) + case registerForPushNotificationToken + case retrySync + case rescan + case lock + case promptBiometrics + case promptPaperKey + case promptUpgradePin + case loginFromSend + case blockModalDismissal + case unblockModalDismissal + case recommendRescan + case scanQr + case copyWalletAddresses(String?, String?) + case hideStatusBar + case showStatusBar + case lightWeightAlert(String) + case didCreateOrRecoverWallet + case showAlert(UIAlertController?) + case reinitWalletManager((() -> Void)?) + case didUpgradePin + case txMemoUpdated(String) + case promptShareData + case didEnableShareData + case didWritePaperKey +} // NB : remember to add to triggers to == fuction below + +extension TriggerName: Equatable {} + +func == (lhs: TriggerName, rhs: TriggerName) -> Bool { + switch (lhs, rhs) { + case (.presentFaq(_), .presentFaq(_)): + return true + case (.registerForPushNotificationToken, .registerForPushNotificationToken): + return true + case (.retrySync, .retrySync): + return true + case (.rescan, .rescan): + return true + case (.lock, .lock): + return true + case (.promptBiometrics, .promptBiometrics): + return true + case (.promptPaperKey, .promptPaperKey): + return true + case (.promptUpgradePin, .promptUpgradePin): + return true + case (.loginFromSend, .loginFromSend): + return true + case (.blockModalDismissal, .blockModalDismissal): + return true + case (.unblockModalDismissal, .unblockModalDismissal): + return true + case (.recommendRescan, .recommendRescan): + return true + case (.scanQr, .scanQr): + return true + case (.copyWalletAddresses(_, _), .copyWalletAddresses(_, _)): + return true + case (.showStatusBar, .showStatusBar): + return true + case (.hideStatusBar, .hideStatusBar): + return true + case (.lightWeightAlert(_), .lightWeightAlert(_)): + return true + case (.didCreateOrRecoverWallet, .didCreateOrRecoverWallet): + return true + case (.showAlert(_), .showAlert(_)): + return true + case (.reinitWalletManager(_), .reinitWalletManager(_)): + return true + case (.didUpgradePin, .didUpgradePin): + return true + case (.txMemoUpdated(_), .txMemoUpdated(_)): + return true + case (.promptShareData, .promptShareData): + return true + case (.didEnableShareData, .didEnableShareData): + return true + case (.didWritePaperKey, .didWritePaperKey): + return true + default: + return false + } +} + +class Store { + // MARK: - Public + + func perform(action: Action) { + state = action.reduce(state) + } + + func trigger(name: TriggerName) { + triggers + .flatMap { $0.value } + .filter { $0.name == name } + .forEach { $0.callback(name) } + } + + // Subscription callback is immediately called with current State value on subscription + // and then any time the selected value changes + func subscribe(_ subscriber: Subscriber, selector: @escaping Selector, callback: @escaping (ReduxState) -> Void) + { + lazySubscribe(subscriber, selector: selector, callback: callback) + callback(state) + } + + // Same as subscribe(), but doesn't call the callback with current state upon subscription + func lazySubscribe(_ subscriber: Subscriber, selector: @escaping Selector, callback: @escaping (ReduxState) -> Void) + { + let key = subscriber.hashValue + let subscription = Subscription(selector: selector, callback: callback) + if subscriptions[key] != nil { + subscriptions[key]?.append(subscription) + } else { + subscriptions[key] = [subscription] + } + } + + func subscribe(_ subscriber: Subscriber, name: TriggerName, callback: @escaping (TriggerName?) -> Void) + { + let key = subscriber.hashValue + let trigger = Trigger(name: name, callback: callback) + if triggers[key] != nil { + triggers[key]?.append(trigger) + } else { + triggers[key] = [trigger] + } + } + + func unsubscribe(_ subscriber: Subscriber) { + subscriptions.removeValue(forKey: subscriber.hashValue) + triggers.removeValue(forKey: subscriber.hashValue) + } + + // MARK: - Private + + private(set) var state = ReduxState.initial { + didSet { + subscriptions + .flatMap { $0.value } // Retreive all subscriptions (subscriptions is a dictionary) + .filter { $0.selector(oldValue, state) } + .forEach { $0.callback(state) } + } + } + + func removeAllSubscriptions() { + subscriptions.removeAll() + triggers.removeAll() + } + + private var subscriptions: [Int: [Subscription]] = [:] + private var triggers: [Int: [Trigger]] = [:] +} diff --git a/litewallet/src/SimpleRedux/Actions.swift b/litewallet/src/SimpleRedux/Actions.swift new file mode 100644 index 000000000..82315a16f --- /dev/null +++ b/litewallet/src/SimpleRedux/Actions.swift @@ -0,0 +1,741 @@ +import UIKit + +// MARK: - Startup Modals + +struct ShowStartFlow: Action { + let reduce: Reducer = { + $0.clone(isStartFlowVisible: true) + } +} + +struct HideStartFlow: Action { + let reduce: Reducer = { state in + ReduxState(isStartFlowVisible: false, + isLoginRequired: state.isLoginRequired, + rootModal: .none, + walletState: state.walletState, + isLtcSwapped: state.isLtcSwapped, + currentRate: state.currentRate, + rates: state.rates, + alert: state.alert, + isBiometricsEnabled: state.isBiometricsEnabled, + defaultCurrencyCode: state.defaultCurrencyCode, + recommendRescan: state.recommendRescan, + isLoadingTransactions: state.isLoadingTransactions, + maxDigits: state.maxDigits, + isPushNotificationsEnabled: state.isPushNotificationsEnabled, + isPromptingBiometrics: state.isPromptingBiometrics, + pinLength: state.pinLength, + fees: state.fees) + } +} + +struct Reset: Action { + let reduce: Reducer = { _ in + ReduxState.initial.clone(isLoginRequired: false) + } +} + +struct RequireLogin: Action { + let reduce: Reducer = { + $0.clone(isLoginRequired: true) + } +} + +struct LoginSuccess: Action { + let reduce: Reducer = { + $0.clone(isLoginRequired: false) + } +} + +// MARK: - Root Modals + +struct RootModalActions { + struct Present: Action { + let reduce: Reducer + init(modal: RootModal) { + reduce = { $0.rootModal(modal) } + } + } +} + +// MARK: - Wallet State + +enum WalletChange { + struct setProgress: Action { + let reduce: Reducer + init(progress: Double, timestamp: UInt32) { + reduce = { $0.clone(walletSyncProgress: progress, timestamp: timestamp) } + } + } + + struct setSyncingState: Action { + let reduce: Reducer + init(_ syncState: SyncState) { + reduce = { $0.clone(syncState: syncState) } + } + } + + struct setBalance: Action { + let reduce: Reducer + init(_ balance: UInt64) { + reduce = { $0.clone(balance: balance) } + } + } + + struct setTransactions: Action { + let reduce: Reducer + init(_ transactions: [Transaction]) { + reduce = { $0.clone(transactions: transactions) } + } + } + + struct setWalletName: Action { + let reduce: Reducer + init(_ name: String) { + reduce = { $0.clone(walletName: name) } + } + } + + struct setWalletCreationDate: Action { + let reduce: Reducer + init(_ date: Date) { + reduce = { $0.clone(walletCreationDate: date) } + } + } + + struct setIsRescanning: Action { + let reduce: Reducer + init(_ isRescanning: Bool) { + reduce = { $0.clone(isRescanning: isRescanning) } + } + } +} + +// MARK: - Currency + +enum CurrencyChange { + struct toggle: Action { + let reduce: Reducer = { + UserDefaults.isLtcSwapped = !$0.isLtcSwapped + return $0.clone(isLtcSwapped: !$0.isLtcSwapped) + } + } +} + +// MARK: - Exchange Rates + +enum ExchangeRates { + struct setRates: Action { + let reduce: Reducer + init(currentRate: Rate, rates: [Rate]) { + UserDefaults.currentRateData = currentRate.dictionary + reduce = { $0.clone(currentRate: currentRate, rates: rates) } + } + } + + struct setRate: Action { + let reduce: Reducer + init(_ currentRate: Rate) { + reduce = { $0.clone(currentRate: currentRate) } + } + } +} + +// MARK: - Alerts + +enum SimpleReduxAlert { + struct Show: Action { + let reduce: Reducer + init(_ type: AlertType) { + reduce = { $0.clone(alert: type) } + } + } + + struct Hide: Action { + let reduce: Reducer = { $0.clone(alert: nil) } + } +} + +enum Biometrics { + struct setIsEnabled: Action, Trackable { + let reduce: Reducer + init(_ isBiometricsEnabled: Bool) { + UserDefaults.isBiometricsEnabled = isBiometricsEnabled + reduce = { $0.clone(isBiometricsEnabled: isBiometricsEnabled) } + saveEvent("event.enableBiometrics", attributes: ["isEnabled": "\(isBiometricsEnabled)"]) + } + } +} + +enum DefaultCurrency { + struct setDefault: Action, Trackable { + let reduce: Reducer + init(_ defaultCurrencyCode: String) { + UserDefaults.defaultCurrencyCode = defaultCurrencyCode + reduce = { $0.clone(defaultCurrencyCode: defaultCurrencyCode) } + saveEvent("event.setDefaultCurrency", attributes: ["code": defaultCurrencyCode]) + } + } +} + +enum RecommendRescan { + struct set: Action, Trackable { + let reduce: Reducer + init(_ recommendRescan: Bool) { + reduce = { $0.clone(recommendRescan: recommendRescan) } + saveEvent("event.recommendRescan") + } + } +} + +enum LoadTransactions { + struct set: Action { + let reduce: Reducer + init(_ isLoadingTransactions: Bool) { + reduce = { $0.clone(isLoadingTransactions: isLoadingTransactions) } + } + } +} + +enum MaxDigits { + struct set: Action, Trackable { + let reduce: Reducer + init(_ maxDigits: Int) { + UserDefaults.maxDigits = maxDigits + reduce = { $0.clone(maxDigits: maxDigits) } + saveEvent("maxDigits.set", attributes: ["maxDigits": "\(maxDigits)"]) + } + } +} + +enum biometricsActions { + struct setIsPrompting: Action { + let reduce: Reducer + init(_ isPrompting: Bool) { + reduce = { $0.clone(isPromptingBiometrics: isPrompting) } + } + } +} + +enum PinLength { + struct set: Action { + let reduce: Reducer + init(_ pinLength: Int) { + reduce = { $0.clone(pinLength: pinLength) } + } + } +} + +enum UpdateFees { + struct set: Action { + let reduce: Reducer + init(_ fees: Fees) { + reduce = { $0.clone(fees: fees) } + } + } +} + +// MARK: - State Creation Helpers + +extension ReduxState { + func clone(isStartFlowVisible: Bool) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func rootModal(_ type: RootModal) -> ReduxState { + return ReduxState(isStartFlowVisible: false, + isLoginRequired: isLoginRequired, + rootModal: type, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(pasteboard _: String?) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(walletSyncProgress: Double, timestamp: UInt32) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: WalletState(isConnected: walletState.isConnected, syncProgress: walletSyncProgress, syncState: walletState.syncState, balance: walletState.balance, transactions: walletState.transactions, lastBlockTimestamp: timestamp, name: walletState.name, creationDate: walletState.creationDate, isRescanning: walletState.isRescanning), + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(syncState: SyncState) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: WalletState(isConnected: walletState.isConnected, syncProgress: walletState.syncProgress, syncState: syncState, balance: walletState.balance, transactions: walletState.transactions, lastBlockTimestamp: walletState.lastBlockTimestamp, name: walletState.name, creationDate: walletState.creationDate, isRescanning: walletState.isRescanning), + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(balance: UInt64) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: WalletState(isConnected: walletState.isConnected, syncProgress: walletState.syncProgress, syncState: walletState.syncState, balance: balance, transactions: walletState.transactions, lastBlockTimestamp: walletState.lastBlockTimestamp, name: walletState.name, creationDate: walletState.creationDate, isRescanning: walletState.isRescanning), + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(transactions: [Transaction]) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: WalletState(isConnected: walletState.isConnected, syncProgress: walletState.syncProgress, syncState: walletState.syncState, balance: walletState.balance, transactions: transactions, lastBlockTimestamp: walletState.lastBlockTimestamp, name: walletState.name, creationDate: walletState.creationDate, isRescanning: walletState.isRescanning), + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(walletName: String) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: WalletState(isConnected: walletState.isConnected, syncProgress: walletState.syncProgress, syncState: walletState.syncState, balance: walletState.balance, transactions: walletState.transactions, lastBlockTimestamp: walletState.lastBlockTimestamp, name: walletName, creationDate: walletState.creationDate, isRescanning: walletState.isRescanning), + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(walletSyncingErrorMessage _: String?) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: WalletState(isConnected: walletState.isConnected, syncProgress: walletState.syncProgress, syncState: walletState.syncState, balance: walletState.balance, transactions: walletState.transactions, lastBlockTimestamp: walletState.lastBlockTimestamp, name: walletState.name, creationDate: walletState.creationDate, isRescanning: walletState.isRescanning), + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(walletCreationDate: Date) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: WalletState(isConnected: walletState.isConnected, syncProgress: walletState.syncProgress, syncState: walletState.syncState, balance: walletState.balance, transactions: walletState.transactions, lastBlockTimestamp: walletState.lastBlockTimestamp, name: walletState.name, creationDate: walletCreationDate, isRescanning: walletState.isRescanning), + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(isRescanning: Bool) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: WalletState(isConnected: walletState.isConnected, syncProgress: walletState.syncProgress, syncState: walletState.syncState, balance: walletState.balance, transactions: walletState.transactions, lastBlockTimestamp: walletState.lastBlockTimestamp, name: walletState.name, creationDate: walletState.creationDate, isRescanning: isRescanning), + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(isLtcSwapped: Bool) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(isLoginRequired: Bool) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(currentRate: Rate, rates: [Rate]) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(currentRate: Rate) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(alert: AlertType?) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(isBiometricsEnabled: Bool) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(defaultCurrencyCode: String) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(recommendRescan: Bool) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(isLoadingTransactions: Bool) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(maxDigits: Int) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(isPushNotificationsEnabled: Bool) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(isPromptingBiometrics: Bool) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(pinLength: Int) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } + + func clone(fees: Fees) -> ReduxState { + return ReduxState(isStartFlowVisible: isStartFlowVisible, + isLoginRequired: isLoginRequired, + rootModal: rootModal, + walletState: walletState, + isLtcSwapped: isLtcSwapped, + currentRate: currentRate, + rates: rates, + alert: alert, + isBiometricsEnabled: isBiometricsEnabled, + defaultCurrencyCode: defaultCurrencyCode, + recommendRescan: recommendRescan, + isLoadingTransactions: isLoadingTransactions, + maxDigits: maxDigits, + isPushNotificationsEnabled: isPushNotificationsEnabled, + isPromptingBiometrics: isPromptingBiometrics, + pinLength: pinLength, + fees: fees) + } +} diff --git a/litewallet/src/SimpleRedux/ReduxState.swift b/litewallet/src/SimpleRedux/ReduxState.swift new file mode 100644 index 000000000..3111fad19 --- /dev/null +++ b/litewallet/src/SimpleRedux/ReduxState.swift @@ -0,0 +1,82 @@ +import UIKit + +struct ReduxState { + let isStartFlowVisible: Bool + let isLoginRequired: Bool + let rootModal: RootModal + let walletState: WalletState + let isLtcSwapped: Bool + let currentRate: Rate? + let rates: [Rate] + let alert: AlertType? + let isBiometricsEnabled: Bool + let defaultCurrencyCode: String + let recommendRescan: Bool + let isLoadingTransactions: Bool + let maxDigits: Int + let isPushNotificationsEnabled: Bool + let isPromptingBiometrics: Bool + let pinLength: Int + let fees: Fees +} + +extension ReduxState { + static var initial: ReduxState { + return ReduxState(isStartFlowVisible: false, + isLoginRequired: true, + rootModal: .none, + walletState: WalletState.initial, + isLtcSwapped: UserDefaults.isLtcSwapped, + currentRate: UserDefaults.currentRate, + rates: [], + alert: nil, + isBiometricsEnabled: UserDefaults.isBiometricsEnabled, + defaultCurrencyCode: UserDefaults.defaultCurrencyCode, + recommendRescan: false, + isLoadingTransactions: false, + maxDigits: UserDefaults.maxDigits, + isPushNotificationsEnabled: UserDefaults.pushToken != nil, + isPromptingBiometrics: false, + pinLength: 6, + fees: Fees.usingDefaultValues) + } +} + +enum RootModal { + case none + case send + case receive + case menu + case loginAddress + case loginScan + case manageWallet + case requestAmount + case wipeEmptyWallet +} + +enum SyncState { + case syncing + case connecting + case success +} + +struct WalletState { + let isConnected: Bool + let syncProgress: Double + let syncState: SyncState + let balance: UInt64? + let transactions: [Transaction] + let lastBlockTimestamp: UInt32 + let name: String + let creationDate: Date + let isRescanning: Bool + static var initial: WalletState { + return WalletState(isConnected: false, syncProgress: 0.0, syncState: .success, balance: nil, transactions: [], lastBlockTimestamp: 0, name: S.AccountHeader.defaultWalletName.localize(), creationDate: Date.zeroValue(), isRescanning: false) + } +} + +extension WalletState: Equatable {} + +func == (lhs: WalletState, rhs: WalletState) -> Bool { + return lhs.isConnected == rhs.isConnected && lhs.syncProgress == rhs.syncProgress && lhs.syncState == rhs.syncState && lhs.balance == rhs.balance && lhs.transactions == rhs.transactions && lhs.name == rhs.name && lhs.creationDate == rhs.creationDate && lhs.isRescanning == rhs.isRescanning +} diff --git a/litewallet/src/StartView.swift b/litewallet/src/StartView.swift new file mode 100644 index 000000000..60cacbd9e --- /dev/null +++ b/litewallet/src/StartView.swift @@ -0,0 +1,212 @@ +import SwiftUI +import UIKit + +struct StartView: View { + let buttonFont: Font = .barlowSemiBold(size: 20.0) + let buttonLightFont: Font = .barlowLight(size: 20.0) + let tinyFont: Font = .barlowRegular(size: 12.0) + + let squareButtonSize: CGFloat = 55.0 + let squareImageSize: CGFloat = 25.0 + + @ObservedObject + var startViewModel: StartViewModel + + @State + private var selectedLang: Bool = false + + @State + private var delayedSelect: Bool = false + + @State + private var currentTagline = "" + + @State + private var animationAmount = 0.0 + + @State + private var pickedLanguage: LanguageSelection = .English + + @State + private var didContinue: Bool = false + + init(viewModel: StartViewModel) { + startViewModel = viewModel + } + + var body: some View { + GeometryReader { geometry in + + let width = geometry.size.width + let height = geometry.size.height + + NavigationView { + ZStack { + Color.litewalletDarkBlue.edgesIgnoringSafeArea(.all) + VStack { + Spacer() + Image("new-logotype-white") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: width * 0.6) + .padding(.top, height * 0.05) + .padding(.bottom, height * 0.05) + + Text(currentTagline) + .font(buttonLightFont) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, + vertical: true) + .foregroundColor(.white) + .frame(width: width * 0.7, + height: height * 0.05, + alignment: .center) + .padding(.top, height * 0.02) + .padding(.bottom, height * 0.08) + .onAppear { + currentTagline = startViewModel.staticTagline + } + HStack { + ZStack { + Picker("", selection: $pickedLanguage) { + ForEach(startViewModel.languages, id: \.self) { + Text($0.nativeName) + .font(selectedLang ? buttonFont : buttonLightFont) + .foregroundColor(.white) + } + } + .pickerStyle(.wheel) + .frame(width: width * 0.5) + .onChange(of: $pickedLanguage.wrappedValue) { _ in + startViewModel.currentLanguage = pickedLanguage + selectedLang = true + currentTagline = startViewModel.taglines[startViewModel.currentLanguage.rawValue] + startViewModel.speakLanguage() + delay(1.5) { + delayedSelect = true + } + } + + Image(systemName: "checkmark.message.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: squareImageSize, + height: squareImageSize, + alignment: .center) + .foregroundColor(selectedLang ? + .litewalletGreen : .litecoinGray.opacity(0.4)) + .shadow(radius: 6, x: 3.0, y: 3.0) + .padding(.all, 4.0) + .frame(width: width * 0.3, + height: squareButtonSize, + alignment: .center) + .offset(CGSize(width: width * 0.18, + height: -height * 0.03)) + .scaleEffect(CGSize(width: animationAmount, height: animationAmount)) + .animation( + .easeInOut(duration: 1.8) + .repeatCount(5), + value: animationAmount + ) + .onAppear { + animationAmount = 1.0 + } + } + } + .frame(width: width * 0.9, + height: height * 0.1, + alignment: .center) + .alert(startViewModel + .alertMessage[startViewModel.currentLanguage.rawValue], + isPresented: $delayedSelect) { + HStack { + Button(S.Button.yes.localize(), role: .cancel) { + startViewModel.setLanguage(code: startViewModel.currentLanguage.code) + selectedLang = false + } + Button(S.Button.cancel.localize(), role: .destructive) { + // Dismisses + selectedLang = false + } + } + } + Spacer() + NavigationLink(destination: + + AnnounceUpdatesView(navigateStart: .create, + language: startViewModel.currentLanguage, + didTapContinue: $didContinue) + .environmentObject(startViewModel) + .navigationBarBackButtonHidden(true) + ) { + ZStack { + RoundedRectangle(cornerRadius: bigButtonCornerRadius) + .frame(width: width * 0.9, height: 45, alignment: .center) + .foregroundColor(.white) + .shadow(radius: 3, x: 3.0, y: 3.0) + + Text(S.StartViewController.createButton.localize()) + .frame(width: width * 0.9, height: 45, alignment: .center) + .font(buttonFont) + .foregroundColor(.litewalletBlue) + .overlay( + RoundedRectangle(cornerRadius: bigButtonCornerRadius) + .stroke(.white, lineWidth: 2.0) + ) + } + } + .padding([.top, .bottom], 10.0) + + NavigationLink(destination: + + AnnounceUpdatesView(navigateStart: .recover, + language: startViewModel.currentLanguage, + didTapContinue: $didContinue) + .environmentObject(startViewModel) + .navigationBarBackButtonHidden(true) + ) { + ZStack { + RoundedRectangle(cornerRadius: bigButtonCornerRadius) + .frame(width: width * 0.9, height: 45, alignment: .center) + .foregroundColor(Color(UIColor.liteWalletBlue) + ).shadow(radius: 5, x: 3.0, y: 3.0) + + Text(S.StartViewController.recoverButton.localize()) + .frame(width: width * 0.9, height: 45, alignment: .center) + .font(buttonLightFont) + .foregroundColor(Color(UIColor.litecoinWhite)) + .overlay( + RoundedRectangle(cornerRadius: bigButtonCornerRadius) + .stroke(.white) + ) + } + } + .padding([.top, .bottom], 10.0) + + Text(AppVersion.string) + .frame(width: 100, alignment: .center) + .font(tinyFont) + .foregroundColor(.white) + .padding(.all, 5.0) + } + .padding(.all, swiftUICellPadding) + } + } + } + .alert(S.LitewalletAlert.error.localize(), + isPresented: $startViewModel.walletCreationDidFail, + actions: { + HStack { + Button(S.Button.ok.localize(), role: .cancel) { + startViewModel.walletCreationDidFail = false + } + } + }) + } +} + +// #Preview { +// StartView(viewModel: StartViewModel(store: Store(), +// walletManager: WalletManager(store: Store()))) +// .environment(\.locale, .init(identifier: "en")) +// } diff --git a/litewallet/src/UserDefaultsUpdater.swift b/litewallet/src/UserDefaultsUpdater.swift new file mode 100644 index 000000000..5cde83d75 --- /dev/null +++ b/litewallet/src/UserDefaultsUpdater.swift @@ -0,0 +1,23 @@ +import Foundation + +private enum AppGroup { + static let id = "group.com.litecoin.loafwallet" + static let requestDataKey = "kBRSharedContainerDataWalletRequestDataKey" + static let receiveAddressKey = "kBRSharedContainerDataWalletReceiveAddressKey" +} + +class UserDefaultsUpdater { + init(walletManager: WalletManager) { + self.walletManager = walletManager + } + + func refresh() { + guard let wallet = walletManager.wallet else { return } + defaults?.set(wallet.receiveAddress as NSString, forKey: AppGroup.receiveAddressKey) + defaults?.set(wallet.receiveAddress.data(using: .utf8), forKey: AppGroup.requestDataKey) + } + + private lazy var defaults: UserDefaults? = UserDefaults(suiteName: AppGroup.id) + + private let walletManager: WalletManager +} diff --git a/litewallet/src/ViewControllers/AboutViewController.swift b/litewallet/src/ViewControllers/AboutViewController.swift new file mode 100644 index 000000000..e2010d1c2 --- /dev/null +++ b/litewallet/src/ViewControllers/AboutViewController.swift @@ -0,0 +1,110 @@ +import SafariServices +import UIKit + +class AboutViewController: UIViewController { + private var titleLabel = UILabel(font: .customBold(size: 26.0), color: .darkText) + private let logo = UIImageView(image: #imageLiteral(resourceName: "coinBlueWhite")) + private let logoBackground = UIView() + private let blog = AboutCell(text: S.About.blog.localize()) + private let twitter = AboutCell(text: S.About.twitter.localize()) + private let reddit = AboutCell(text: S.About.reddit.localize()) + private let privacy = UIButton(type: .system) + private let footer = UILabel(font: .customBody(size: 13.0), color: .secondaryGrayText) + override func viewDidLoad() { + if #available(iOS 11.0, *), + let labelTextColor = UIColor(named: "labelTextColor"), + let backgroundColor = UIColor(named: "lfBackgroundColor") + { + titleLabel.textColor = labelTextColor + privacy.tintColor = labelTextColor + view.backgroundColor = backgroundColor + } else { + privacy.tintColor = .liteWalletBlue + view.backgroundColor = .whiteTint + } + + addSubviews() + addConstraints() + setData() + setActions() + } + + private func addSubviews() { + view.addSubview(titleLabel) + view.addSubview(logoBackground) + logoBackground.addSubview(logo) + view.addSubview(blog) + view.addSubview(twitter) + view.addSubview(reddit) + view.addSubview(privacy) + view.addSubview(footer) + } + + private func addConstraints() { + titleLabel.constrain([ + titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: C.padding[2]), + ]) + logoBackground.constrain([ + logoBackground.centerXAnchor.constraint(equalTo: view.centerXAnchor), + logoBackground.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: C.padding[3]), + logoBackground.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.2), + logoBackground.heightAnchor.constraint(equalTo: logoBackground.widthAnchor, multiplier: 1.0), + ]) + logo.constrain(toSuperviewEdges: nil) + blog.constrain([ + blog.topAnchor.constraint(equalTo: logoBackground.bottomAnchor, constant: C.padding[2]), + blog.leadingAnchor.constraint(equalTo: view.leadingAnchor), + blog.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + twitter.constrain([ + twitter.topAnchor.constraint(equalTo: blog.bottomAnchor, constant: C.padding[2]), + twitter.leadingAnchor.constraint(equalTo: view.leadingAnchor), + twitter.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + reddit.constrain([ + reddit.topAnchor.constraint(equalTo: twitter.bottomAnchor, constant: C.padding[2]), + reddit.leadingAnchor.constraint(equalTo: view.leadingAnchor), + reddit.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + privacy.constrain([ + privacy.centerXAnchor.constraint(equalTo: view.centerXAnchor), + privacy.topAnchor.constraint(equalTo: reddit.bottomAnchor, constant: C.padding[2]), + ]) + footer.constrain([ + footer.centerXAnchor.constraint(equalTo: view.centerXAnchor), + footer.topAnchor.constraint(equalTo: privacy.bottomAnchor), + footer.heightAnchor.constraint(equalToConstant: 80), + ]) + } + + private func setData() { + titleLabel.text = S.Settings.socialLinks.localize() + privacy.setTitle(S.About.privacy.localize(), for: .normal) + privacy.titleLabel?.font = UIFont.customBody(size: 13.0) + footer.textAlignment = .center + footer.numberOfLines = 4 + footer.text = String(format: S.About.footer.localize(), AppVersion.string) + logo.contentMode = .scaleAspectFill + } + + private func setActions() { + blog.button.tap = strongify(self) { myself in + myself.presentURL(string: "https://lite-wallet.org") + } + twitter.button.tap = strongify(self) { myself in + myself.presentURL(string: "https://twitter.com/Litewallet_App") + } + reddit.button.tap = strongify(self) { myself in + myself.presentURL(string: "https://www.reddit.com/r/Litewallet/") + } + privacy.tap = strongify(self) { myself in + myself.presentURL(string: "https://litewallet.io/privacy/policy.html") + } + } + + private func presentURL(string: String) { + let vc = SFSafariViewController(url: URL(string: string)!) + present(vc, animated: true, completion: nil) + } +} diff --git a/litewallet/src/ViewControllers/AmountViewController.swift b/litewallet/src/ViewControllers/AmountViewController.swift new file mode 100644 index 000000000..ef3048d71 --- /dev/null +++ b/litewallet/src/ViewControllers/AmountViewController.swift @@ -0,0 +1,409 @@ +import UIKit + +private let currencyToggleConstant: CGFloat = 20.0 +private let amountFont: UIFont = UIFont.barlowMedium(size: 14.0) +class AmountViewController: UIViewController, Trackable { + private let store: Store + private let isPinPadExpandedAtLaunch: Bool + private let isRequesting: Bool + var minimumFractionDigits = 2 + private var hasTrailingDecimal = false + private var pinPadHeight: NSLayoutConstraint? + private var feeSelectorHeight: NSLayoutConstraint? + private var feeSelectorTop: NSLayoutConstraint? + private var placeholder = PaddingLabel(font: amountFont, color: .grayTextTint) + private var amountLabel = UILabel(font: amountFont, color: .darkText) + private let pinPad: PinPadViewController + private let currencyToggle: ShadowButton + private let border = UIView(color: .secondaryShadow) + private let bottomBorder = UIView(color: .secondaryShadow) + private let cursor = BlinkingView(blinkColor: C.defaultTintColor) + private let balanceLabel = UILabel() + private let feeLabel = UILabel() + private let feeContainer = InViewAlert(type: .secondary) + private let tapView = UIView() + private let editFee = UIButton(type: .system) + private let feeSelector: FeeSelector + + private var amount: Satoshis? { + didSet { + updateAmountLabel() + updateBalanceLabel() + didUpdateAmount?(amount) + } + } + + var balanceTextForAmount: ((Satoshis?, Rate?) -> (NSAttributedString?, NSAttributedString?)?)? + var didUpdateAmount: ((Satoshis?) -> Void)? + var didChangeFirstResponder: ((Bool) -> Void)? + var didShowFiat: ((_ isShowingFiat: Bool) -> Void)? + + var currentOutput: String { + return amountLabel.text ?? "" + } + + var selectedRate: Rate? { + didSet { + fullRefresh() + } + } + + var didUpdateFee: ((FeeType) -> Void)? { + didSet { + feeSelector.didUpdateFee = didUpdateFee + } + } + + init(store: Store, + isPinPadExpandedAtLaunch: Bool, + hasAcceptedFees _: Bool, + isRequesting: Bool = false) + { + self.store = store + self.isPinPadExpandedAtLaunch = isPinPadExpandedAtLaunch + self.isRequesting = isRequesting + if let rate = store.state.currentRate, store.state.isLtcSwapped { + currencyToggle = ShadowButton(title: "\(rate.code)(\(rate.currencySymbol))", type: .tertiary) + } else { + currencyToggle = ShadowButton(title: S.Symbols.currencyButtonTitle(maxDigits: store.state.maxDigits), type: .tertiary) + } + feeSelector = FeeSelector(store: store) + pinPad = PinPadViewController(style: .white, keyboardType: .decimalPad, maxDigits: store.state.maxDigits) + super.init(nibName: nil, bundle: nil) + } + + func forceUpdateAmount(amount: Satoshis) { + self.amount = amount + fullRefresh() + } + + func expandPinPad() { + if pinPadHeight?.constant == 0.0 { + togglePinPad() + } + } + + override func viewDidLoad() { + if #available(iOS 11.0, *) { + guard let headerTextColor = UIColor(named: "headerTextColor"), + let textColor = UIColor(named: "labelTextColor") + else { + NSLog("ERROR: Custom color") + return + } + placeholder.textColor = headerTextColor + amountLabel.textColor = textColor + } else { + placeholder.textColor = .grayTextTint + amountLabel.textColor = .darkText + } + + addSubviews() + addConstraints() + setInitialData() + } + + private func addSubviews() { + view.addSubview(placeholder) + view.addSubview(currencyToggle) + view.addSubview(feeContainer) + view.addSubview(border) + view.addSubview(cursor) + view.addSubview(balanceLabel) + view.addSubview(feeLabel) + view.addSubview(tapView) + view.addSubview(amountLabel) + view.addSubview(bottomBorder) + view.addSubview(editFee) + } + + private func addConstraints() { + amountLabel.constrain([ + amountLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: swiftUICellPadding), + amountLabel.centerYAnchor.constraint(equalTo: currencyToggle.centerYAnchor), + amountLabel.heightAnchor.constraint(equalToConstant: 44.0), + ]) + + placeholder.constrain([ + placeholder.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: swiftUICellPadding), + placeholder.centerYAnchor.constraint(equalTo: amountLabel.centerYAnchor), + placeholder.heightAnchor.constraint(equalToConstant: 44.0), + ]) + cursor.constrain([ + cursor.leadingAnchor.constraint(equalTo: amountLabel.trailingAnchor, constant: 2.0), + cursor.heightAnchor.constraint(equalToConstant: 24.0), + cursor.centerYAnchor.constraint(equalTo: amountLabel.centerYAnchor), + cursor.widthAnchor.constraint(equalToConstant: 2.0), + ]) + currencyToggle.constrain([ + currencyToggle.topAnchor.constraint(equalTo: view.topAnchor, constant: currencyToggleConstant), + currencyToggle.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + ]) + feeSelectorHeight = feeContainer.heightAnchor.constraint(equalToConstant: 0.0) + feeSelectorTop = feeContainer.topAnchor.constraint(equalTo: feeLabel.bottomAnchor, constant: 0.0) + + feeContainer.constrain([ + feeSelectorTop, + feeSelectorHeight, + feeContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), + feeContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + feeContainer.arrowXLocation = C.padding[4] + + let borderTop = isRequesting ? border.topAnchor.constraint(equalTo: currencyToggle.bottomAnchor, constant: C.padding[2]) : border.topAnchor.constraint(equalTo: feeContainer.bottomAnchor) + border.constrain([ + border.leadingAnchor.constraint(equalTo: view.leadingAnchor), + borderTop, + border.trailingAnchor.constraint(equalTo: view.trailingAnchor), + border.heightAnchor.constraint(equalToConstant: 1.0), + ]) + balanceLabel.constrain([ + balanceLabel.leadingAnchor.constraint(equalTo: amountLabel.leadingAnchor), + balanceLabel.topAnchor.constraint(equalTo: cursor.bottomAnchor, constant: 10.0), + ]) + feeLabel.constrain([ + feeLabel.leadingAnchor.constraint(equalTo: balanceLabel.leadingAnchor), + feeLabel.topAnchor.constraint(equalTo: balanceLabel.bottomAnchor), + ]) + pinPadHeight = pinPad.view.heightAnchor.constraint(equalToConstant: 0.0) + addChildViewController(pinPad, layout: { + pinPad.view.constrain([ + pinPad.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + pinPad.view.topAnchor.constraint(equalTo: border.bottomAnchor), + pinPad.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + pinPad.view.bottomAnchor.constraint(equalTo: bottomBorder.topAnchor), + pinPadHeight, + ]) + }) + editFee.constrain([ + editFee.leadingAnchor.constraint(equalTo: feeLabel.trailingAnchor, constant: -8.0), + editFee.centerYAnchor.constraint(equalTo: feeLabel.centerYAnchor, constant: -1.0), + editFee.widthAnchor.constraint(equalToConstant: 44.0), + editFee.heightAnchor.constraint(equalToConstant: 44.0), + ]) + bottomBorder.constrain([ + bottomBorder.topAnchor.constraint(greaterThanOrEqualTo: currencyToggle.bottomAnchor, constant: C.padding[2]), + bottomBorder.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bottomBorder.bottomAnchor.constraint(equalTo: view.bottomAnchor), + bottomBorder.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bottomBorder.heightAnchor.constraint(equalToConstant: 1.0), + ]) + + tapView.constrain([ + tapView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tapView.topAnchor.constraint(equalTo: view.topAnchor), + tapView.trailingAnchor.constraint(equalTo: currencyToggle.leadingAnchor, constant: 4.0), + tapView.bottomAnchor.constraint(equalTo: feeContainer.topAnchor), + ]) + preventAmountOverflow() + } + + private func setInitialData() { + cursor.isHidden = true + cursor.startBlinking() + + amountLabel.text = "" + + placeholder.backgroundColor = .white + placeholder.layer.cornerRadius = 8.0 + placeholder.layer.masksToBounds = true + + amountLabel.backgroundColor = .white + amountLabel.layer.cornerRadius = 8.0 + amountLabel.layer.masksToBounds = true + + placeholder.text = S.Send.amountLabel.localize() + bottomBorder.isHidden = true + if store.state.isLtcSwapped { + if let rate = store.state.currentRate { + selectedRate = rate + } + } + pinPad.ouputDidUpdate = { [weak self] output in + self?.handlePinPadUpdate(output: output) + } + currencyToggle.tap = strongify(self) { myself in + myself.toggleCurrency() + } + let gr = UITapGestureRecognizer(target: self, action: #selector(didTap)) + tapView.addGestureRecognizer(gr) + tapView.isUserInteractionEnabled = true + + if isPinPadExpandedAtLaunch { + didTap() + } + + feeContainer.contentView = feeSelector + editFee.tap = { [weak self] in + self?.toggleFeeSelector() + } + editFee.setImage(#imageLiteral(resourceName: "Edit"), for: .normal) + editFee.imageEdgeInsets = UIEdgeInsets(top: 15.0, left: 15.0, bottom: 15.0, right: 15.0) + editFee.tintColor = .grayTextTint + editFee.isHidden = true + } + + private func toggleCurrency() { + saveEvent("amount.swapCurrency") + selectedRate = selectedRate == nil ? store.state.currentRate : nil + updateCurrencyToggleTitle() + } + + private func preventAmountOverflow() { + amountLabel.constrain([ + amountLabel.trailingAnchor.constraint(lessThanOrEqualTo: currencyToggle.leadingAnchor, constant: -C.padding[2]), + ]) + amountLabel.minimumScaleFactor = 0.95 + amountLabel.adjustsFontSizeToFitWidth = true + amountLabel.setContentCompressionResistancePriority(UILayoutPriority.defaultLow, for: .horizontal) + } + + private func handlePinPadUpdate(output: String) { + let currencyDecimalSeparator = NumberFormatter().currencyDecimalSeparator ?? "." + placeholder.isHidden = output.utf8.count > 0 ? true : false + minimumFractionDigits = 0 // set default + if let decimalLocation = output.range(of: currencyDecimalSeparator)?.upperBound { + let locationValue = output.distance(from: output.endIndex, to: decimalLocation) + minimumFractionDigits = abs(locationValue) + } + + // If trailing decimal, append the decimal to the output + hasTrailingDecimal = false // set default + if let decimalLocation = output.range(of: currencyDecimalSeparator)?.upperBound { + if output.endIndex == decimalLocation { + hasTrailingDecimal = true + } + } + + var newAmount: Satoshis? + if let outputAmount = NumberFormatter().number(from: output)?.doubleValue { + if let rate = selectedRate { + newAmount = Satoshis(value: outputAmount, rate: rate) + } else { + if store.state.maxDigits == 5 { + let bits = Bits(rawValue: outputAmount * 1000) + newAmount = Satoshis(bits: bits) + } else { + let bitcoin = Bitcoin(rawValue: outputAmount) + newAmount = Satoshis(bitcoin: bitcoin) + } + } + } + + if let newAmount = newAmount { + if newAmount > C.maxMoney { + pinPad.removeLast() + } else { + amount = newAmount + } + } else { + amount = nil + } + } + + private func updateAmountLabel() { + guard let amount = amount else { amountLabel.text = ""; return } + let displayAmount = DisplayAmount(amount: amount, state: store.state, selectedRate: selectedRate, minimumFractionDigits: minimumFractionDigits) + var output = displayAmount.description + if hasTrailingDecimal { + output = output.appending(NumberFormatter().currencyDecimalSeparator) + } + amountLabel.text = output + placeholder.isHidden = output.utf8.count > 0 ? true : false + } + + func updateBalanceLabel() { + if let (balance, fee) = balanceTextForAmount?(amount, selectedRate) { + balanceLabel.attributedText = balance + feeLabel.attributedText = fee + if let amount = amount, amount > 0, !isRequesting { + editFee.isHidden = false + } else { + editFee.isHidden = true + } + balanceLabel.isHidden = cursor.isHidden + } + } + + private func toggleFeeSelector() { + guard let height = feeSelectorHeight else { return } + let isCollapsed: Bool = height.isActive + UIView.spring(C.animationDuration, animations: { + if isCollapsed { + NSLayoutConstraint.deactivate([height]) + self.feeSelector.addIntrinsicSize() + } else { + self.feeSelector.removeIntrinsicSize() + NSLayoutConstraint.activate([height]) + } + self.parent?.parent?.view?.layoutIfNeeded() + }, completion: { _ in }) + } + + @objc private func didTap() { + UIView.spring(C.animationDuration, animations: { + self.togglePinPad() + self.parent?.parent?.view.layoutIfNeeded() + }, completion: { _ in }) + } + + func closePinPad() { + pinPadHeight?.constant = 0.0 + cursor.isHidden = true + bottomBorder.isHidden = true + updateBalanceAndFeeLabels() + updateBalanceLabel() + } + + private func togglePinPad() { + let isCollapsed: Bool = pinPadHeight?.constant == 0.0 + pinPadHeight?.constant = isCollapsed ? pinPad.height : 0.0 + cursor.isHidden = isCollapsed ? false : true + bottomBorder.isHidden = isCollapsed ? false : true + updateBalanceAndFeeLabels() + updateBalanceLabel() + didChangeFirstResponder?(isCollapsed) + } + + private func updateBalanceAndFeeLabels() { + if let amount = amount, amount.rawValue > 0 { + balanceLabel.isHidden = false + if !isRequesting { + editFee.isHidden = false + } + } else { + balanceLabel.isHidden = cursor.isHidden + if !isRequesting { + editFee.isHidden = true + } + } + } + + private func fullRefresh() { + updateCurrencyToggleTitle() + updateBalanceLabel() + updateAmountLabel() + + // Update pinpad content to match currency change + // This must be done AFTER the amount label has updated + let currentOutput = amountLabel.text ?? "" + var set = CharacterSet.decimalDigits + set.formUnion(CharacterSet(charactersIn: NumberFormatter().currencyDecimalSeparator)) + pinPad.currentOutput = String(String.UnicodeScalarView(currentOutput.unicodeScalars.filter { set.contains($0) })) + } + + private func updateCurrencyToggleTitle() { + if let rate = selectedRate { + currencyToggle.title = "\(rate.code)(\(rate.currencySymbol))" + didShowFiat?(false) + } else { + currencyToggle.title = S.Symbols.currencyButtonTitle(maxDigits: store.state.maxDigits) + didShowFiat?(true) + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/BiometricsSettingsViewController.swift b/litewallet/src/ViewControllers/BiometricsSettingsViewController.swift new file mode 100644 index 000000000..97197ae1a --- /dev/null +++ b/litewallet/src/ViewControllers/BiometricsSettingsViewController.swift @@ -0,0 +1,180 @@ +import LocalAuthentication +import UIKit + +class BiometricsSettingsViewController: UIViewController, Subscriber { + var presentSpendingLimit: (() -> Void)? + var didDismiss: (() -> Void)? + + init(walletManager: WalletManager, store: Store) { + self.walletManager = walletManager + self.store = store + super.init(nibName: nil, bundle: nil) + } + + private let header = RadialGradientView(backgroundColor: .darkPurple) + private let illustration = LAContext.biometricType() == .face ? UIImageView(image: #imageLiteral(resourceName: "FaceId-Large")) : UIImageView(image: #imageLiteral(resourceName: "TouchId-Large")) + private var label = UILabel.wrapping(font: .customBody(size: 16.0), color: .darkText) + private var switchLabel = UILabel(font: .customBold(size: 14.0), color: .darkText) + private var spendingLimitLabel = UILabel(font: .customBold(size: 14.0), color: .darkText) + private var spendingButton = ShadowButton(title: "-", type: .secondary) + private var dismissButton = UIButton() + + private let toggle = GradientSwitch() + private let separator = UIView(color: .secondaryShadow) + private let walletManager: WalletManager + private let store: Store + private var rate: Rate? + fileprivate var didTapSpendingLimit = false + + deinit { + store.unsubscribe(self) + } + + override func viewDidLoad() { + store.subscribe(self, selector: { $0.currentRate != $1.currentRate }, callback: { + self.rate = $0.currentRate + }) + + addSubviews() + addConstraints() + setData() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + didTapSpendingLimit = false + spendingButton.title = spendingButtonText + } + + private func addSubviews() { + view.addSubview(header) + header.addSubview(illustration) + view.addSubview(label) + view.addSubview(switchLabel) + view.addSubview(toggle) + view.addSubview(spendingLimitLabel) + view.addSubview(spendingButton) + view.addSubview(separator) + view.addSubview(dismissButton) + } + + private func addConstraints() { + header.constrainTopCorners(sidePadding: 0.0, topPadding: 0.0) + header.constrain([header.heightAnchor.constraint(equalToConstant: C.Sizes.largeHeaderHeight)]) + illustration.constrain([ + illustration.centerXAnchor.constraint(equalTo: header.centerXAnchor), + illustration.centerYAnchor.constraint(equalTo: header.centerYAnchor, constant: E.isIPhoneX ? C.padding[4] : C.padding[2]), + ]) + dismissButton.constrain([ + dismissButton.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: C.padding[2]), + dismissButton.topAnchor.constraint(equalTo: view.topAnchor, constant: C.padding[2]), + ]) + label.constrain([ + label.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: C.padding[2]), + label.topAnchor.constraint(equalTo: header.bottomAnchor, constant: C.padding[2]), + label.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -C.padding[2]), + ]) + switchLabel.constrain([ + switchLabel.leadingAnchor.constraint(equalTo: label.leadingAnchor), + switchLabel.topAnchor.constraint(equalTo: label.bottomAnchor, constant: C.padding[2]), + ]) + toggle.constrain([ + toggle.centerYAnchor.constraint(equalTo: switchLabel.centerYAnchor), + toggle.trailingAnchor.constraint(equalTo: label.trailingAnchor), + ]) + spendingLimitLabel.constrain([ + spendingLimitLabel.leadingAnchor.constraint(equalTo: label.leadingAnchor), + spendingLimitLabel.topAnchor.constraint(equalTo: switchLabel.bottomAnchor, constant: C.padding[4]), + ]) + spendingButton.constrain([ + spendingButton.centerYAnchor.constraint(equalTo: spendingLimitLabel.centerYAnchor), + spendingButton.trailingAnchor.constraint(equalTo: label.trailingAnchor), + ]) + separator.constrain([ + separator.leadingAnchor.constraint(equalTo: switchLabel.leadingAnchor), + separator.topAnchor.constraint(equalTo: spendingButton.bottomAnchor, constant: C.padding[1]), + separator.trailingAnchor.constraint(equalTo: spendingButton.trailingAnchor), + separator.heightAnchor.constraint(equalToConstant: 1.0), + ]) + } + + private func setData() { + spendingButton.addTarget(self, action: #selector(didTapSpendingButton), for: .touchUpInside) + if #available(iOS 11.0, *), let backGroundColor = UIColor(named: "lfBackgroundColor"), + let textColor = UIColor(named: "labelTextColor") + { + label.textColor = textColor + switchLabel.textColor = textColor + spendingLimitLabel.textColor = textColor + view.backgroundColor = backGroundColor + } else { + label.textColor = .darkText + switchLabel.textColor = .darkText + view.backgroundColor = .white + } + + title = LAContext.biometricType() == .face ? S.FaceIDSettings.title.localize() : S.TouchIdSettings.title.localize() + label.text = LAContext.biometricType() == .face ? S.FaceIDSettings.label.localize() : S.TouchIdSettings.label.localize() + switchLabel.text = LAContext.biometricType() == .face ? S.FaceIDSettings.switchLabel.localize() : S.TouchIdSettings.switchLabel.localize() + spendingLimitLabel.text = S.SpendingLimit.titleLabel.localize() + spendingButton.title = spendingButtonText + let hasSetToggleInitialValue = false + store.subscribe(self, selector: { $0.isBiometricsEnabled != $1.isBiometricsEnabled }, callback: { + self.toggle.isOn = $0.isBiometricsEnabled + if !hasSetToggleInitialValue { + self.toggle.sendActions(for: .valueChanged) // This event is needed because the gradient background gets set on valueChanged events + } + }) + toggle.valueChanged = { [weak self] in + guard let myself = self else { return } + + if LAContext.canUseBiometrics { + myself.store.perform(action: Biometrics.setIsEnabled(myself.toggle.isOn)) + myself.spendingButton.title = myself.spendingButtonText + } else { + myself.presentCantUseBiometricsAlert() + myself.toggle.isOn = false + } + } + + dismissButton.setImage(#imageLiteral(resourceName: "Back"), for: .normal) + dismissButton.addTarget(self, action: #selector(didTapDismissButton), for: .touchUpInside) + } + + private var spendingButtonText: String { + guard let rate = rate else { return "" } + let amount = Amount(amount: walletManager.spendingLimit, rate: rate, maxDigits: store.state.maxDigits) + let string = "\(String(format: S.TouchIdSettings.limitValue.localize(), amount.bits, amount.localCurrency))" + return string + } + + fileprivate func presentCantUseBiometricsAlert() { + let unavailableAlertTitle = LAContext.biometricType() == .face ? S.FaceIDSettings.unavailableAlertTitle.localize() : S.TouchIdSettings.unavailableAlertTitle.localize() + let unavailableAlertMessage = LAContext.biometricType() == .face ? S.FaceIDSettings.unavailableAlertMessage.localize() : S.TouchIdSettings.unavailableAlertMessage.localize() + let alert = UIAlertController(title: unavailableAlertTitle, message: unavailableAlertMessage, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: S.Button.ok.localize(), style: .cancel, handler: nil)) + present(alert, animated: true, completion: nil) + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + @objc func didTapSpendingButton() { + if LAContext.canUseBiometrics { + didTapSpendingLimit = true + presentSpendingLimit?() + } else { + presentCantUseBiometricsAlert() + } + } + + @objc func didTapDismissButton() { + didDismiss?() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/BiometricsSpendingLimitViewController.swift b/litewallet/src/ViewControllers/BiometricsSpendingLimitViewController.swift new file mode 100644 index 000000000..0bc2e72c0 --- /dev/null +++ b/litewallet/src/ViewControllers/BiometricsSpendingLimitViewController.swift @@ -0,0 +1,114 @@ +import LocalAuthentication +import UIKit + +class BiometricsSpendingLimitViewController: UITableViewController, Subscriber { + private let cellIdentifier = "CellIdentifier" + private let store: Store + private let walletManager: WalletManager + private let limits: [UInt64] = [0, 10_000_000, 100_000_000, 1_000_000_000, 10_000_000_000] + private var selectedLimit: UInt64? + private var header: UIView? + private let amount = UILabel(font: .customMedium(size: 26.0), color: .darkText) + private let body = UILabel.wrapping(font: .customBody(size: 13.0), color: .darkText) + + init(walletManager: WalletManager, store: Store) { + self.walletManager = walletManager + self.store = store + super.init(nibName: nil, bundle: nil) + } + + override func viewDidLoad() { + if limits.contains(walletManager.spendingLimit) { + selectedLimit = walletManager.spendingLimit + } + tableView.register(SeparatorCell.self, forCellReuseIdentifier: cellIdentifier) + tableView.sectionHeaderHeight = UITableView.automaticDimension + tableView.estimatedSectionHeaderHeight = 50.0 + tableView.backgroundColor = .whiteTint + tableView.separatorStyle = .none + + let titleLabel = UILabel(font: .customBold(size: 17.0), color: .darkText) + let biometricsTitle = LAContext.biometricType() == .face ? S.FaceIdSpendingLimit.title.localize() : S.TouchIdSpendingLimit.title.localize() + titleLabel.text = biometricsTitle + titleLabel.sizeToFit() + navigationItem.titleView = titleLabel + + let faqButton = UIButton.buildFaqButton(store: store, articleId: ArticleIds.nothing) + faqButton.tintColor = .darkText + navigationItem.rightBarButtonItems = [UIBarButtonItem.negativePadding, UIBarButtonItem(customView: faqButton)] + + body.text = S.TouchIdSpendingLimit.body.localize() + + // If the user has a limit that is not a current option, we display their limit + if !limits.contains(walletManager.spendingLimit) { + if let rate = store.state.currentRate { + let spendingLimit = Amount(amount: walletManager.spendingLimit, rate: rate, maxDigits: store.state.maxDigits) + setAmount(limitAmount: spendingLimit) + } + } + } + + override func numberOfSections(in _: UITableView) -> Int { + return 1 + } + + override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { + return limits.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell + { + let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) + let limit = limits[indexPath.row] + if limit == 0 { + cell.textLabel?.text = S.TouchIdSpendingLimit.requirePasscode.localize() + } else { + let displayAmount = DisplayAmount(amount: Satoshis(rawValue: limit), state: store.state, selectedRate: nil, minimumFractionDigits: 0) + cell.textLabel?.text = displayAmount.combinedDescription + } + if limits[indexPath.row] == selectedLimit { + let check = UIImageView(image: #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate)) + check.tintColor = C.defaultTintColor + cell.accessoryView = check + } else { + cell.accessoryView = nil + } + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let newLimit = limits[indexPath.row] + selectedLimit = newLimit + walletManager.spendingLimit = newLimit + amount.isHidden = true + amount.constrain([ + amount.heightAnchor.constraint(equalToConstant: 0.0), + ]) + tableView.reloadData() + } + + override func tableView(_: UITableView, viewForHeaderInSection _: Int) -> UIView? { + if let header = self.header { return header } + let header = UIView(color: .whiteTint) + header.addSubview(amount) + header.addSubview(body) + amount.pinTopLeft(padding: C.padding[2]) + body.constrain([ + body.leadingAnchor.constraint(equalTo: amount.leadingAnchor), + body.topAnchor.constraint(equalTo: amount.bottomAnchor), + body.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -C.padding[2]), + body.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -C.padding[2]), + ]) + self.header = header + return header + } + + private func setAmount(limitAmount: Amount) { + amount.text = "\(limitAmount.bits) = \(limitAmount.localCurrency)" + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/ConfirmPaperPhraseViewController.swift b/litewallet/src/ViewControllers/ConfirmPaperPhraseViewController.swift new file mode 100644 index 000000000..45f74fc47 --- /dev/null +++ b/litewallet/src/ViewControllers/ConfirmPaperPhraseViewController.swift @@ -0,0 +1,203 @@ +import UIKit + +class ConfirmPaperPhraseViewController: UITableViewController { + var didCompleteConfirmation: (() -> Void)? + + @IBOutlet var headerView: UIView! + @IBOutlet var backButton: UIButton! + @IBOutlet var headerTitleLabel: UILabel! + @IBOutlet var headerDescriptionLabel: UILabel! + @IBOutlet var firstWordCell: ConfirmPhraseTableViewCell! + @IBOutlet var secondWordCell: ConfirmPhraseTableViewCell! + @IBOutlet var thirdWordCell: ConfirmPhraseTableViewCell! + @IBOutlet var fourthWordCell: ConfirmPhraseTableViewCell! + @IBOutlet var footerView: UIView! + @IBOutlet var submitButton: UIButton! + + private let fourIndices: (Int, Int, Int, Int) = { + var indexSet = Set(arrayLiteral: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) + let first = indexSet.randomElement()! + indexSet.remove(first) + var second = indexSet.randomElement()! + indexSet.remove(second) + var third = indexSet.randomElement()! + indexSet.remove(third) + var fourth = indexSet.randomElement()! + return (first, second, third, fourth) + }() + + private lazy var words: [String] = { + guard let pin = self.pin, + let phraseString = self.walletManager?.seedPhrase(pin: pin) + else { + NSLog("Error: Phrase string empty") + return [] + } + var wordArray = phraseString.components(separatedBy: " ") + let lastWord = wordArray.last + if let trimmed = lastWord?.replacingOccurrences(of: "\0", with: "") { + wordArray[11] = trimmed // This end line \0 is being read as an element...removing it + } + return wordArray + }() + + private lazy var confirmFirstPhrase: ConfirmPhrase = { ConfirmPhrase(text: String(format: S.ConfirmPaperPhrase.word.localize(), "\(self.fourIndices.0 + 1)"), word: self.words[self.fourIndices.0]) + }() + + private lazy var confirmSecondPhrase: ConfirmPhrase = { ConfirmPhrase(text: String(format: S.ConfirmPaperPhrase.word.localize(), "\(self.fourIndices.1 + 1)"), word: self.words[self.fourIndices.1]) }() + private lazy var confirmThirdPhrase: ConfirmPhrase = { ConfirmPhrase(text: String(format: S.ConfirmPaperPhrase.word.localize(), "\(self.fourIndices.2 + 1)"), word: self.words[self.fourIndices.2]) }() + private lazy var confirmFourthPhrase: ConfirmPhrase = { ConfirmPhrase(text: String(format: S.ConfirmPaperPhrase.word.localize(), "\(self.fourIndices.3 + 1)"), word: self.words[self.fourIndices.3]) }() + + var store: Store? + var walletManager: WalletManager? + var pin: String? + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func viewWillAppear(_: Bool) { + firstWordCell.confirmPhraseView = confirmFirstPhrase + secondWordCell.confirmPhraseView = confirmSecondPhrase + thirdWordCell.confirmPhraseView = confirmThirdPhrase + fourthWordCell.confirmPhraseView = confirmFourthPhrase + } + + override func viewDidLoad() { + view.backgroundColor = .white + navigationController?.navigationBar.isHidden = true + setupSubViews() + firstWordCell.confirmPhraseView?.textField.becomeFirstResponder() + + NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, + object: nil, + queue: nil) { [weak self] _ in + self?.dismiss(animated: true, + completion: nil) + } + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + override var prefersStatusBarHidden: Bool { + return true + } + + private func setupSubViews() { + headerView.backgroundColor = .liteWalletBlue + headerTitleLabel.font = UIFont.barlowBold(size: 18.0) + headerDescriptionLabel.font = UIFont.barlowRegular(size: 14.0) + + headerTitleLabel.text = S.SecurityCenter.Cells.paperKeyTitle.localize() + headerDescriptionLabel.text = S.ConfirmPaperPhrase.label.localize() + headerTitleLabel.textColor = .white + headerDescriptionLabel.textColor = .white + + firstWordCell.addSubview(confirmFirstPhrase) + firstWordCell.addConstraints(NSLayoutConstraint.constraints( + withVisualFormat: "|-[confirmFirstPhrase]-|", options: [], metrics: nil, + views: ["confirmFirstPhrase": confirmFirstPhrase] + )) + + secondWordCell.addSubview(confirmSecondPhrase) + secondWordCell.addConstraints(NSLayoutConstraint.constraints( + withVisualFormat: "|-[confirmSecondPhrase]-|", options: [], metrics: nil, + views: ["confirmSecondPhrase": confirmSecondPhrase] + )) + + thirdWordCell.addSubview(confirmThirdPhrase) + thirdWordCell.addConstraints(NSLayoutConstraint.constraints( + withVisualFormat: "|-[confirmThirdPhrase]-|", options: [], metrics: nil, + views: ["confirmThirdPhrase": confirmThirdPhrase] + )) + + fourthWordCell.addSubview(confirmFourthPhrase) + fourthWordCell.addConstraints(NSLayoutConstraint.constraints( + withVisualFormat: "|-[confirmFourthPhrase]-|", options: [], metrics: nil, + views: ["confirmFourthPhrase": confirmFourthPhrase] + )) + + backButton.addTarget(self, action: #selector(dismissController), for: .touchUpInside) + submitButton.setTitle(S.Button.submit.localize(), for: .normal) + submitButton.titleLabel?.font = UIFont.barlowBold(size: 18.0) + submitButton.backgroundColor = .liteWalletBlue + submitButton.layer.cornerRadius = 4.0 + submitButton.clipsToBounds = true + submitButton.addTarget(self, action: #selector(checkPhrases), for: .touchUpInside) + + confirmFirstPhrase.callback = { [weak self] in + if self?.confirmFirstPhrase.textField.text == self?.confirmFirstPhrase.word { + self?.confirmSecondPhrase.textField.becomeFirstResponder() + } + } + confirmFirstPhrase.isEditingCallback = { [weak self] in + self?.adjustScrollView(set: 1) + } + confirmSecondPhrase.callback = { [weak self] in + if self?.confirmSecondPhrase.textField.text == self?.confirmSecondPhrase.word { + self?.confirmThirdPhrase.textField.becomeFirstResponder() + } + } + confirmSecondPhrase.isEditingCallback = { [weak self] in + self?.adjustScrollView(set: 2) + } + confirmThirdPhrase.callback = { [weak self] in + if self?.confirmThirdPhrase.textField.text == self?.confirmThirdPhrase.word { + self?.confirmFourthPhrase.textField.becomeFirstResponder() + } + } + confirmThirdPhrase.isEditingCallback = { [weak self] in + self?.adjustScrollView(set: 3) + } + confirmFourthPhrase.isEditingCallback = { [weak self] in + self?.adjustScrollView(set: 4) + } + } + + private func adjustScrollView(set: Int) { + let constant = 20.0 + let offset = CGFloat(constant) * CGFloat(set) + tableView.setContentOffset(CGPoint(x: 0, y: offset), animated: true) + } + + @objc private func dismissController() { + dismiss(animated: true) + } + + @objc private func checkPhrases() { + guard let store = store + else { + NSLog("ERROR: Store not initialized") + return + } + + if firstWordCell.confirmPhraseView?.textField.text == words[fourIndices.0] && + secondWordCell.confirmPhraseView?.textField.text == words[fourIndices.1] && + thirdWordCell.confirmPhraseView?.textField.text == words[fourIndices.2] && + fourthWordCell.confirmPhraseView?.textField.text == words[fourIndices.3] + { + UserDefaults.writePaperPhraseDate = Date() + store.trigger(name: .didWritePaperKey) + didCompleteConfirmation?() + } else { + firstWordCell.confirmPhraseView?.validate() + secondWordCell.confirmPhraseView?.validate() + thirdWordCell.confirmPhraseView?.validate() + fourthWordCell.confirmPhraseView?.validate() + showErrorMessage(S.ConfirmPaperPhrase.error.localize()) + } + } +} + +class ConfirmPhraseTableViewCell: UITableViewCell { + var confirmPhraseView: ConfirmPhrase? + override func awakeFromNib() { + super.awakeFromNib() + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + } +} diff --git a/litewallet/src/ViewControllers/ConfirmationViewController.swift b/litewallet/src/ViewControllers/ConfirmationViewController.swift new file mode 100644 index 000000000..22e3a6f94 --- /dev/null +++ b/litewallet/src/ViewControllers/ConfirmationViewController.swift @@ -0,0 +1,230 @@ +import LocalAuthentication +import UIKit + +class ConfirmationViewController: UIViewController, ContentBoxPresenter { + init(amount: Satoshis, + txFee: Satoshis, + opsFee: Satoshis, + feeType: FeeType, + state: ReduxState, + selectedRate: Rate?, + minimumFractionDigits: Int?, + address: String, isUsingBiometrics: Bool, isDonation _: Bool = false) + { + self.amount = amount + self.txFee = txFee + self.opsFee = opsFee + self.feeType = feeType + self.state = state + self.selectedRate = selectedRate + self.minimumFractionDigits = minimumFractionDigits + addressText = address + self.isUsingBiometrics = isUsingBiometrics + + header = ModalHeaderView(title: S.Confirmation.title.localize(), style: .dark) + super.init(nibName: nil, bundle: nil) + } + + private let amount: Satoshis + private let txFee: Satoshis + private let opsFee: Satoshis + private let feeType: FeeType + private let state: ReduxState + private let selectedRate: Rate? + private let minimumFractionDigits: Int? + private let addressText: String + private let isUsingBiometrics: Bool + + // ContentBoxPresenter + let contentBox = UIView(color: .white) + let blurView = UIVisualEffectView() + let effect = UIBlurEffect(style: .dark) + + var successCallback: (() -> Void)? + var cancelCallback: (() -> Void)? + + private var header: ModalHeaderView? + private let cancel = ShadowButton(title: S.Button.cancel.localize(), type: .flatWhiteBorder) + private let sendButton = ShadowButton(title: S.Confirmation.send.localize(), type: .flatLitecoinBlue, image: LAContext.biometricType() == .face ? #imageLiteral(resourceName: "FaceId") : #imageLiteral(resourceName: "TouchId")) + + private let payLabel = UILabel(font: .barlowLight(size: 15.0), color: .grayTextTint) + private let toLabel = UILabel(font: .barlowLight(size: 15.0), color: .grayTextTint) + private let amountLabel = UILabel(font: .barlowRegular(size: 15.0), color: .darkText) + private let address = UILabel(font: .barlowRegular(size: 15.0), color: .darkText) + + private let processingTime = UILabel.wrapping(font: .barlowLight(size: 14.0), color: .grayTextTint) + private let sendLabel = UILabel(font: .barlowLight(size: 14.0), color: .darkGray) + private let feeLabel = UILabel(font: .barlowLight(size: 14.0), color: .darkGray) + private let totalLabel = UILabel(font: .barlowLight(size: 14.0), color: .darkGray) + + private let send = UILabel(font: .barlowRegular(size: 15.0), color: .darkText) + private let fee = UILabel(font: .barlowRegular(size: 15.0), color: .darkText) + private let total = UILabel(font: .barlowMedium(size: 15.0), color: .darkText) + + override func viewDidLoad() { + DispatchQueue.main.async { + self.addSubviews() + self.addConstraints() + self.setInitialData() + } + } + + private func addSubviews() { + view.addSubview(contentBox) + + guard let header = header + else { + NSLog("ERROR: Header not initialized") + return + } + + contentBox.addSubview(header) + contentBox.addSubview(payLabel) + contentBox.addSubview(toLabel) + contentBox.addSubview(amountLabel) + contentBox.addSubview(address) + contentBox.addSubview(processingTime) + contentBox.addSubview(sendLabel) + contentBox.addSubview(feeLabel) + contentBox.addSubview(totalLabel) + contentBox.addSubview(send) + contentBox.addSubview(fee) + contentBox.addSubview(total) + contentBox.addSubview(cancel) + contentBox.addSubview(sendButton) + } + + private func addConstraints() { + guard let header = header + else { + NSLog("ERROR: Header not initialized") + return + } + + contentBox.constrain([ + contentBox.centerXAnchor.constraint(equalTo: view.centerXAnchor), + contentBox.centerYAnchor.constraint(equalTo: view.centerYAnchor), + contentBox.widthAnchor.constraint(equalTo: view.widthAnchor, constant: -C.padding[6]), + ]) + header.constrainTopCorners(height: 49.0) + payLabel.constrain([ + payLabel.leadingAnchor.constraint(equalTo: contentBox.leadingAnchor, constant: C.padding[2]), + payLabel.topAnchor.constraint(equalTo: header.bottomAnchor, constant: C.padding[2]), + ]) + amountLabel.constrain([ + amountLabel.leadingAnchor.constraint(equalTo: payLabel.leadingAnchor), + amountLabel.topAnchor.constraint(equalTo: payLabel.bottomAnchor), + ]) + toLabel.constrain([ + toLabel.leadingAnchor.constraint(equalTo: amountLabel.leadingAnchor), + toLabel.topAnchor.constraint(equalTo: amountLabel.bottomAnchor, constant: C.padding[2]), + ]) + address.constrain([ + address.leadingAnchor.constraint(equalTo: toLabel.leadingAnchor), + address.topAnchor.constraint(equalTo: toLabel.bottomAnchor), + address.trailingAnchor.constraint(equalTo: contentBox.trailingAnchor, constant: -C.padding[2]), + ]) + processingTime.constrain([ + processingTime.leadingAnchor.constraint(equalTo: address.leadingAnchor), + processingTime.topAnchor.constraint(equalTo: address.bottomAnchor, constant: C.padding[2]), + processingTime.trailingAnchor.constraint(equalTo: contentBox.trailingAnchor, constant: -C.padding[2]), + ]) + sendLabel.constrain([ + sendLabel.leadingAnchor.constraint(equalTo: processingTime.leadingAnchor), + sendLabel.topAnchor.constraint(equalTo: processingTime.bottomAnchor, constant: C.padding[2]), + ]) + send.constrain([ + send.trailingAnchor.constraint(equalTo: contentBox.trailingAnchor, constant: -C.padding[2]), + sendLabel.firstBaselineAnchor.constraint(equalTo: send.firstBaselineAnchor), + ]) + feeLabel.constrain([ + feeLabel.leadingAnchor.constraint(equalTo: sendLabel.leadingAnchor), + feeLabel.topAnchor.constraint(equalTo: sendLabel.bottomAnchor), + ]) + fee.constrain([ + fee.trailingAnchor.constraint(equalTo: contentBox.trailingAnchor, constant: -C.padding[2]), + fee.firstBaselineAnchor.constraint(equalTo: feeLabel.firstBaselineAnchor), + ]) + totalLabel.constrain([ + totalLabel.leadingAnchor.constraint(equalTo: feeLabel.leadingAnchor), + totalLabel.topAnchor.constraint(equalTo: feeLabel.bottomAnchor), + ]) + total.constrain([ + total.trailingAnchor.constraint(equalTo: contentBox.trailingAnchor, constant: -C.padding[2]), + total.firstBaselineAnchor.constraint(equalTo: totalLabel.firstBaselineAnchor), + ]) + cancel.constrain([ + cancel.leadingAnchor.constraint(equalTo: contentBox.leadingAnchor, constant: C.padding[2]), + cancel.topAnchor.constraint(equalTo: totalLabel.bottomAnchor, constant: C.padding[2]), + cancel.trailingAnchor.constraint(equalTo: contentBox.centerXAnchor, constant: -C.padding[1]), + cancel.bottomAnchor.constraint(equalTo: contentBox.bottomAnchor, constant: -C.padding[2]), + ]) + sendButton.constrain([ + sendButton.leadingAnchor.constraint(equalTo: contentBox.centerXAnchor, constant: C.padding[1]), + sendButton.topAnchor.constraint(equalTo: totalLabel.bottomAnchor, constant: C.padding[2]), + sendButton.trailingAnchor.constraint(equalTo: contentBox.trailingAnchor, constant: -C.padding[2]), + sendButton.bottomAnchor.constraint(equalTo: contentBox.bottomAnchor, constant: -C.padding[2]), + ]) + } + + private func setInitialData() { + view.backgroundColor = .clear + payLabel.text = S.Confirmation.send.localize() + guard let header = header + else { + NSLog("ERROR: Header not initialized") + return + } + + switch feeType { + case .luxury: + processingTime.text = String(format: S.Confirmation.processingTime.localize(), "2.5-5") + case .regular: + processingTime.text = String(format: S.Confirmation.processingTime.localize(), "2.5-5") + case .economy: + processingTime.text = String(format: S.Confirmation.processingTime.localize(), "5+") + } + + let displayAmount = DisplayAmount(amount: amount, state: state, selectedRate: selectedRate, minimumFractionDigits: 2) + let displayFee = DisplayAmount(amount: txFee + opsFee, state: state, selectedRate: selectedRate, minimumFractionDigits: 2) + let displayTotal = DisplayAmount(amount: amount + txFee + opsFee, state: state, selectedRate: selectedRate, minimumFractionDigits: 2) + + toLabel.text = S.Confirmation.to.localize() + feeLabel.text = S.Send.feeBlank.localize() + sendLabel.text = S.Confirmation.amountLabel.localize() + totalLabel.text = S.Confirmation.totalLabel.localize() + + amountLabel.text = displayAmount.combinedDescription + address.text = addressText + + send.text = displayAmount.description + fee.text = displayFee.description.replacingZeroFeeWithTenCents() + total.text = displayTotal.description + + cancel.tap = strongify(self) { myself in + myself.cancelCallback?() + } + header.closeCallback = strongify(self) { myself in + myself.cancelCallback?() + } + sendButton.tap = strongify(self) { myself in + myself.successCallback?() + } + + contentBox.layer.cornerRadius = 6.0 + contentBox.layer.masksToBounds = true + + if !isUsingBiometrics { + sendButton.image = nil + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var prefersStatusBarHidden: Bool { + return true + } +} diff --git a/litewallet/src/ViewControllers/EnterPhraseCollectionViewController.swift b/litewallet/src/ViewControllers/EnterPhraseCollectionViewController.swift new file mode 100644 index 000000000..1c3c479aa --- /dev/null +++ b/litewallet/src/ViewControllers/EnterPhraseCollectionViewController.swift @@ -0,0 +1,111 @@ +import UIKit + +private let itemHeight: CGFloat = 50.0 + +class EnterPhraseCollectionViewController: UICollectionViewController { + // MARK: - Public + + var didFinishPhraseEntry: ((String) -> Void)? + var height: CGFloat { + return itemHeight * 4.0 + } + + #if Debug || Testflight + // let mockPhraseString = MockSeeds.mockPhraseModelX + #endif + + init(walletManager: WalletManager) { + self.walletManager = walletManager + let layout = UICollectionViewFlowLayout() + let screenWidth = UIScreen.main.safeWidth + layout.itemSize = CGSize(width: (screenWidth - C.padding[4]) / 3.0, height: itemHeight) + layout.minimumLineSpacing = 0.0 + layout.minimumInteritemSpacing = 0.0 + layout.sectionInset = .zero + super.init(collectionViewLayout: layout) + } + + // MARK: - Private + + private let cellIdentifier = "CellIdentifier" + private let walletManager: WalletManager + private var phrase: String { + return (0 ... 11).map { index in + guard let phraseCell = collectionView?.cellForItem(at: IndexPath(item: index, section: 0)) as? EnterPhraseCell else { return "" } + return phraseCell.textField.text ?? "" + }.joined(separator: " ") + } + + override func viewDidLoad() { + collectionView = NonScrollingCollectionView(frame: view.bounds, collectionViewLayout: collectionViewLayout) + collectionView?.backgroundColor = .white + collectionView?.register(EnterPhraseCell.self, forCellWithReuseIdentifier: cellIdentifier) + collectionView?.delegate = self + collectionView?.dataSource = self + collectionView?.layer.borderColor = UIColor.secondaryBorder.cgColor + collectionView?.layer.borderWidth = 1.0 + collectionView?.layer.cornerRadius = 8.0 + collectionView?.isScrollEnabled = false + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + becomeFirstResponder(atIndex: 0) + } + + // MARK: - UICollectionViewDataSource + + override func collectionView(_: UICollectionView, numberOfItemsInSection _: Int) -> Int { + return 12 + } + + override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell + { + let item = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath) + guard let enterPhraseCell = item as? EnterPhraseCell else { return item } + enterPhraseCell.index = indexPath.row + enterPhraseCell.didTapNext = { [weak self] in + self?.becomeFirstResponder(atIndex: indexPath.row + 1) + } + enterPhraseCell.didTapPrevious = { [weak self] in + self?.becomeFirstResponder(atIndex: indexPath.row - 1) + } + enterPhraseCell.didTapDone = { [weak self] in + guard let phrase = self?.phrase + else { + NSLog("Phrase not initialized") + return + } + #if Debug || Testflight + // phrase = MockSeeds.mockPhraseModelX + #endif + self?.didFinishPhraseEntry?(phrase) + } + enterPhraseCell.isWordValid = { [weak self] word in + guard let myself = self else { return false } + return myself.walletManager.isWordValid(word) + } + enterPhraseCell.didEnterSpace = { + enterPhraseCell.didTapNext?() + } + + if indexPath.item == 0 { + enterPhraseCell.disablePreviousButton() + } else if indexPath.item == 11 { + enterPhraseCell.disableNextButton() + } + return item + } + + // MARK: - Extras + + private func becomeFirstResponder(atIndex: Int) { + guard let phraseCell = collectionView?.cellForItem(at: IndexPath(item: atIndex, section: 0)) as? EnterPhraseCell else { return } + phraseCell.textField.becomeFirstResponder() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/EnterPhraseViewController.swift b/litewallet/src/ViewControllers/EnterPhraseViewController.swift new file mode 100644 index 000000000..15a0e1bf8 --- /dev/null +++ b/litewallet/src/ViewControllers/EnterPhraseViewController.swift @@ -0,0 +1,218 @@ +import UIKit + +enum PhraseEntryReason { + case setSeed(EnterPhraseCallback) + case validateForResettingPin(EnterPhraseCallback) + case validateForWipingWallet(() -> Void) +} + +typealias EnterPhraseCallback = (String) -> Void + +class EnterPhraseViewController: UIViewController, UIScrollViewDelegate, CustomTitleView, Trackable +{ + init(store: Store, walletManager: WalletManager, reason: PhraseEntryReason) { + self.store = store + self.walletManager = walletManager + enterPhrase = EnterPhraseCollectionViewController(walletManager: walletManager) + faq = UIButton.buildFaqButton(store: store, articleId: ArticleIds.nothing) + self.reason = reason + + switch reason { + case .setSeed: + customTitle = S.RecoverWallet.header.localize() + case .validateForResettingPin: + customTitle = S.RecoverWallet.headerResetPin.localize() + case .validateForWipingWallet: + customTitle = S.WipeWallet.title.localize() + } + + super.init(nibName: nil, bundle: nil) + } + + // MARK: - Private + + private let store: Store + private let walletManager: WalletManager + private let reason: PhraseEntryReason + private let enterPhrase: EnterPhraseCollectionViewController + private let errorLabel = UILabel.wrapping(font: .customBody(size: 16.0), color: .litewalletOrange) + private let instruction = UILabel(font: .customBold(size: 14.0), color: .darkText) + internal let titleLabel = UILabel.wrapping(font: .customBold(size: 26.0), color: .darkText) + private let subheader = UILabel.wrapping(font: .customBody(size: 16.0), color: .darkText) + private let faq: UIButton + private let scrollView = UIScrollView() + private let container = UIView() + private let moreInfoButton = UIButton(type: .system) + let customTitle: String + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func viewDidLoad() { + addSubviews() + addConstraints() + setData() + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(notification:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil) + } + + private func addSubviews() { + view.addSubview(scrollView) + scrollView.addSubview(container) + container.addSubview(titleLabel) + container.addSubview(subheader) + container.addSubview(errorLabel) + container.addSubview(instruction) + container.addSubview(faq) + container.addSubview(moreInfoButton) + + addChild(enterPhrase) + container.addSubview(enterPhrase.view) + enterPhrase.didMove(toParent: self) + } + + private func addConstraints() { + scrollView.constrain(toSuperviewEdges: nil) + scrollView.constrain([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + container.constrain(toSuperviewEdges: nil) + container.constrain([ + container.widthAnchor.constraint(equalTo: view.widthAnchor), + ]) + titleLabel.constrain([ + titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + titleLabel.topAnchor.constraint(equalTo: container.topAnchor, constant: C.padding[1]), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: faq.leadingAnchor), + ]) + subheader.constrain([ + subheader.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + subheader.topAnchor.constraint(equalTo: titleLabel.bottomAnchor), + subheader.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + ]) + instruction.constrain([ + instruction.topAnchor.constraint(equalTo: subheader.bottomAnchor, constant: C.padding[3]), + instruction.leadingAnchor.constraint(equalTo: subheader.leadingAnchor), + ]) + enterPhrase.view.constrain([ + enterPhrase.view.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + enterPhrase.view.topAnchor.constraint(equalTo: instruction.bottomAnchor, constant: C.padding[1]), + enterPhrase.view.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + enterPhrase.view.heightAnchor.constraint(equalToConstant: enterPhrase.height), + ]) + errorLabel.constrain([ + errorLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: C.padding[2]), + errorLabel.topAnchor.constraint(equalTo: enterPhrase.view.bottomAnchor, constant: C.padding[1]), + errorLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -C.padding[2]), + errorLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -C.padding[2]), + ]) + faq.constrain([ + faq.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + faq.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + faq.widthAnchor.constraint(equalToConstant: 44.0), + faq.heightAnchor.constraint(equalToConstant: 44.0), + ]) + moreInfoButton.constrain([ + moreInfoButton.topAnchor.constraint(equalTo: subheader.bottomAnchor, constant: C.padding[2]), + moreInfoButton.leadingAnchor.constraint(equalTo: subheader.leadingAnchor), + ]) + } + + private func setData() { + view.backgroundColor = .secondaryButton + errorLabel.text = S.RecoverWallet.invalid.localize() + errorLabel.isHidden = true + errorLabel.textAlignment = .center + enterPhrase.didFinishPhraseEntry = { [weak self] phrase in + self?.validatePhrase(phrase) + } + instruction.text = S.RecoverWallet.instruction.localize() + + switch reason { + case .setSeed: + saveEvent("enterPhrase.setSeed") + titleLabel.text = S.RecoverWallet.header.localize() + subheader.text = S.RecoverWallet.subheader.localize() + moreInfoButton.isHidden = true + case .validateForResettingPin: + saveEvent("enterPhrase.resettingPin") + titleLabel.text = S.RecoverWallet.headerResetPin.localize() + subheader.text = S.RecoverWallet.subheaderResetPin.localize() + instruction.isHidden = true + moreInfoButton.setTitle(S.RecoverWallet.resetPinInfo.localize(), for: .normal) + moreInfoButton.tap = { [weak self] in + self?.store.trigger(name: .presentFaq(ArticleIds.nothing)) + } + faq.isHidden = true + case .validateForWipingWallet: + saveEvent("enterPhrase.wipeWallet") + titleLabel.text = S.WipeWallet.title.localize() + subheader.text = S.WipeWallet.instruction.localize() + } + + scrollView.delegate = self + addCustomTitle() + } + + private func validatePhrase(_ phrase: String) { + guard walletManager.isPhraseValid(phrase) + else { + saveEvent("enterPhrase.invalid") + errorLabel.isHidden = false + return + } + saveEvent("enterPhrase.valid") + errorLabel.isHidden = true + switch reason { + case let .setSeed(callback): + guard walletManager.setSeedPhrase(phrase) else { errorLabel.isHidden = false; return } + // Since we know that the user had their phrase at this point, + // this counts as a write date + UserDefaults.writePaperPhraseDate = Date() + return callback(phrase) + case let .validateForResettingPin(callback): + guard walletManager.authenticate(phrase: phrase) else { errorLabel.isHidden = false; return } + UserDefaults.writePaperPhraseDate = Date() + return callback(phrase) + case let .validateForWipingWallet(callback): + guard walletManager.authenticate(phrase: phrase) else { errorLabel.isHidden = false; return } + return callback() + } + } + + @objc private func keyboardWillShow(notification: Notification) { + guard let userInfo = notification.userInfo else { return } + guard let frameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return } + var contentInset = scrollView.contentInset + if contentInset.bottom == 0.0 { + contentInset.bottom = frameValue.cgRectValue.height + 44.0 + } + scrollView.contentInset = contentInset + } + + @objc private func keyboardWillHide(notification _: Notification) { + var contentInset = scrollView.contentInset + if contentInset.bottom > 0.0 { + contentInset.bottom = 0.0 + } + scrollView.contentInset = contentInset + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + didScrollForCustomTitle(yOffset: scrollView.contentOffset.y) + } + + func scrollViewWillEndDragging(_: UIScrollView, withVelocity _: CGPoint, targetContentOffset: UnsafeMutablePointer) + { + scrollViewWillEndDraggingForCustomTitle(yOffset: targetContentOffset.pointee.y) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/Import/StartImportViewController.swift b/litewallet/src/ViewControllers/Import/StartImportViewController.swift new file mode 100644 index 000000000..3d59c9f1c --- /dev/null +++ b/litewallet/src/ViewControllers/Import/StartImportViewController.swift @@ -0,0 +1,281 @@ +import BRCore +import UIKit + +private let mainURL = "https://insight.litecore.io/api/addrs/utxo" +private let fallbackURL = "https://insight.litecore.io/api/addrs/utxo" +private let testnetURL = "https://testnet.litecore.io/api/addrs/utxo" + +class StartImportViewController: UIViewController { + init(walletManager: WalletManager, store: Store) { + self.walletManager = walletManager + self.store = store + super.init(nibName: nil, bundle: nil) + } + + private let walletManager: WalletManager + private let store: Store + private let header = RadialGradientView(backgroundColor: .blue, offset: 64.0) + private let illustration = UIImageView(image: #imageLiteral(resourceName: "ImportIllustration")) + private let message = UILabel.wrapping(font: .customBody(size: 16.0), color: .darkText) + private let warning = UILabel.wrapping(font: .customBody(size: 16.0), color: .darkText) + private let button = ShadowButton(title: S.Import.scan.localize(), type: .primary) + private let bullet = UIImageView(image: #imageLiteral(resourceName: "deletecircle")) + private let leftCaption = UILabel.wrapping(font: .customMedium(size: 13.0), color: .darkText) + private let rightCaption = UILabel.wrapping(font: .customMedium(size: 13.0), color: .darkText) + private let balanceActivity = BRActivityViewController(message: S.Import.checking.localize()) + private let importingActivity = BRActivityViewController(message: S.Import.importing.localize()) + private let unlockingActivity = BRActivityViewController(message: S.Import.unlockingActivity.localize()) + + override func viewDidLoad() { + addSubviews() + addConstraints() + setInitialData() + } + + private func addSubviews() { + view.addSubview(header) + header.addSubview(illustration) + header.addSubview(leftCaption) + header.addSubview(rightCaption) + view.addSubview(message) + view.addSubview(button) + view.addSubview(bullet) + view.addSubview(warning) + } + + private func addConstraints() { + header.constrainTopCorners(sidePadding: 0, topPadding: 0) + header.constrain([ + header.constraint(.height, constant: 220.0), + ]) + illustration.constrain([ + illustration.constraint(.width, constant: 64.0), + illustration.constraint(.height, constant: 84.0), + illustration.constraint(.centerX, toView: header, constant: 0.0), + illustration.constraint(.centerY, toView: header, constant: -C.padding[1]), + ]) + leftCaption.constrain([ + leftCaption.topAnchor.constraint(equalTo: illustration.bottomAnchor, constant: C.padding[1]), + leftCaption.trailingAnchor.constraint(equalTo: header.centerXAnchor, constant: -C.padding[2]), + leftCaption.widthAnchor.constraint(equalToConstant: 80.0), + ]) + rightCaption.constrain([ + rightCaption.topAnchor.constraint(equalTo: illustration.bottomAnchor, constant: C.padding[1]), + rightCaption.leadingAnchor.constraint(equalTo: header.centerXAnchor, constant: C.padding[2]), + rightCaption.widthAnchor.constraint(equalToConstant: 80.0), + ]) + message.constrain([ + message.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + message.topAnchor.constraint(equalTo: header.bottomAnchor, constant: C.padding[2]), + message.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + ]) + bullet.constrain([ + bullet.leadingAnchor.constraint(equalTo: message.leadingAnchor), + bullet.topAnchor.constraint(equalTo: message.bottomAnchor, constant: C.padding[4]), + bullet.widthAnchor.constraint(equalToConstant: 16.0), + bullet.heightAnchor.constraint(equalToConstant: 16.0), + ]) + warning.constrain([ + warning.leadingAnchor.constraint(equalTo: bullet.trailingAnchor, constant: C.padding[2]), + warning.topAnchor.constraint(equalTo: bullet.topAnchor, constant: 0.0), + warning.trailingAnchor.constraint(equalTo: message.trailingAnchor), + ]) + button.constrain([ + button.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[3]), + button.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -C.padding[4]), + button.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[3]), + button.constraint(.height, constant: C.Sizes.buttonHeight), + ]) + } + + private func setInitialData() { + view.backgroundColor = .white + illustration.contentMode = .scaleAspectFill + message.text = S.Import.importMessage.localize() + leftCaption.text = S.Import.leftCaption.localize() + leftCaption.textAlignment = .center + rightCaption.text = S.Import.rightCaption.localize() + rightCaption.textAlignment = .center + warning.text = S.Import.importWarning.localize() + + button.tap = { [weak self] in + let scan = ScanViewController(scanKeyCompletion: { keyString in + self?.didReceiveAddress(keyString) + }, isValidURI: { string -> Bool in + string.isValidPrivateKey || string.isValidBip38Key + }) + self?.parent?.present(scan, animated: true, completion: nil) + } + } + + private func didReceiveAddress(_ addressOrKeyString: String) { + if addressOrKeyString.isValidPrivateKey { + if let key = BRKey(privKey: addressOrKeyString) { + checkBalance(key: key) + } + } else if addressOrKeyString.isValidBip38Key { + unlock(address: addressOrKeyString, callback: { key in + self.checkBalance(key: key) + }) + } else { + NSLog("ERROR ADDRESS OR KEY STRING: \(addressOrKeyString)") + } + } + + private func unlock(address: String, callback: @escaping (BRKey) -> Void) { + let alert = UIAlertController(title: S.Import.title.localize(), message: S.Import.password.localize(), preferredStyle: .alert) + alert.addTextField(configurationHandler: { textField in + textField.placeholder = S.Import.passwordPlaceholder.localize() + textField.isSecureTextEntry = true + textField.returnKeyType = .done + }) + alert.addAction(UIAlertAction(title: S.Button.cancel.localize(), style: .cancel, handler: nil)) + alert.addAction(UIAlertAction(title: S.Button.ok.localize(), style: .default, handler: { _ in + self.present(self.unlockingActivity, animated: true, completion: { + if let password = alert.textFields?.first?.text { + if let key = BRKey(bip38Key: address, passphrase: password) { + self.unlockingActivity.dismiss(animated: true, completion: { + callback(key) + }) + return + } + } + self.unlockingActivity.dismiss(animated: true, completion: { + self.showErrorMessage(S.Import.wrongPassword.localize()) + }) + }) + })) + present(alert, animated: true, completion: nil) + } + + private func checkBalance(key: BRKey) { + present(balanceActivity, animated: true, completion: { + var key = key + + guard let address = key.address() + else { + NSLog("KEY ADDRESS: No Key Address") + return + } + + let urlString = E.isTestnet ? testnetURL : mainURL + let request = NSMutableURLRequest(url: URL(string: urlString)!, + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutInterval: 20.0) + request.httpMethod = "POST" + request.httpBody = "addrs=\(address)".data(using: .utf8) + let task = URLSession.shared.dataTask(with: request as URLRequest) + { [weak self] data, _, error in + guard let myself = self else { return } + guard error == nil else { print("error: \(error!)"); return } + guard let data = data, + let jsonData = try? JSONSerialization.jsonObject(with: data, options: []), + let json = jsonData as? [[String: Any]] else { return } + + DispatchQueue.main.async { + myself.handleData(data: json, key: key) + } + } + task.resume() + }) + } + + private func handleData(data: [[String: Any]], key: BRKey) { + var key = key + guard let tx = UnsafeMutablePointer() else { return } + guard let wallet = walletManager.wallet else { return } + guard let address = key.address() else { return } + guard !wallet.containsAddress(address) + else { + return showErrorMessage(S.Import.Error.duplicate.localize()) + } + let outputs = data.compactMap { SimpleUTXO(json: $0) } + let balance = outputs.map { $0.satoshis }.reduce(0, +) + outputs.forEach { output in + tx.addInput(txHash: output.hash, index: output.index, amount: output.satoshis, script: output.script) + } + + let pubKeyLength = key.pubKey()?.count ?? 0 + let fee = wallet.feeForTxSize(tx.size + 34 + (pubKeyLength - 34) * tx.inputs.count) + balanceActivity.dismiss(animated: true, completion: { + guard !outputs.isEmpty, balance > 0 + else { + return self.showErrorMessage(S.Import.Error.empty.localize()) + } + guard fee + wallet.minOutputAmount <= balance + else { + return self.showErrorMessage(S.Import.Error.highFees.localize()) + } + guard let rate = self.store.state.currentRate else { return } + let balanceAmount = Amount(amount: balance, rate: rate, maxDigits: self.store.state.maxDigits) + let feeAmount = Amount(amount: fee, rate: rate, maxDigits: self.store.state.maxDigits) + let balanceText = self.store.state.isLtcSwapped ? balanceAmount.localCurrency : balanceAmount.bits + let feeText = self.store.state.isLtcSwapped ? feeAmount.localCurrency : feeAmount.bits + let message = String(format: S.Import.confirm.localize(), balanceText, feeText) + let alert = UIAlertController(title: S.Import.title.localize(), message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: S.Button.cancel.localize(), style: .cancel, handler: nil)) + alert.addAction(UIAlertAction(title: S.Import.importButton.localize(), style: .default, handler: { _ in + self.publish(tx: tx, balance: balance, fee: fee, key: key) + })) + self.present(alert, animated: true, completion: nil) + }) + } + + private func publish(tx: UnsafeMutablePointer, balance: UInt64, fee: UInt64, key: BRKey) + { + present(importingActivity, animated: true, completion: { + guard let wallet = self.walletManager.wallet else { return } + guard let script = BRAddress(string: wallet.receiveAddress)?.scriptPubKey else { return } + tx.addOutput(amount: balance - fee, script: script) + var keys = [key] + _ = tx.sign(keys: &keys) + + guard tx.isSigned + else { + DispatchQueue.main.async { + self.importingActivity.dismiss(animated: true, completion: { + self.showErrorMessage(S.Import.Error.signing.localize()) + }) + } + return + } + self.walletManager.peerManager?.publishTx(tx, completion: { [weak self] _, error in + guard let myself = self else { return } + DispatchQueue.main.async { + myself.importingActivity.dismiss(animated: true, completion: { + DispatchQueue.main.async { + if let error = error { + myself.showErrorMessage(error.localizedDescription) + return + } + myself.showSuccess() + } + }) + } + }) + }) + } + + private func showSuccess() { + store.perform(action: SimpleReduxAlert.Show(.sweepSuccess(callback: { [weak self] in + guard let myself = self else { return } + myself.dismiss(animated: true, completion: nil) + }))) + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension Data { + var reverse: Data { + let tempBytes = Array([UInt8](self).reversed()) + return Data(bytes: tempBytes) + } +} diff --git a/litewallet/src/ViewControllers/LoginViewController.swift b/litewallet/src/ViewControllers/LoginViewController.swift new file mode 100644 index 000000000..4f2445f07 --- /dev/null +++ b/litewallet/src/ViewControllers/LoginViewController.swift @@ -0,0 +1,493 @@ +import Firebase +import LocalAuthentication +import SwiftUI +import UIKit + +private let squareButtonSize: CGFloat = 32.0 +private let headerHeight: CGFloat = 110 + +protocol LoginViewControllerDelegate { + func didUnlockLogin() +} + +class LoginViewController: UIViewController, Subscriber, Trackable { + // MARK: - Public + + var walletManager: WalletManager? { + didSet { + guard walletManager != nil else { return } + pinView = PinView(style: .login, length: store.state.pinLength) + } + } + + var shouldSelfDismiss = false + + init(store: Store, isPresentedForLock: Bool, walletManager: WalletManager? = nil) { + self.store = store + self.walletManager = walletManager + self.isPresentedForLock = isPresentedForLock + disabledView = WalletDisabledView(store: store) + if walletManager != nil { + pinView = PinView(style: .login, length: store.state.pinLength) + } + + let viewModel = LockScreenViewModel(store: self.store) + headerView = UIHostingController(rootView: LockScreenHeaderView(viewModel: viewModel)) + + super.init(nibName: nil, bundle: nil) + } + + deinit { + store.unsubscribe(self) + } + + // MARK: - Private + + private let store: Store + + private let backgroundView: UIView = { + let view = UIView() + view.backgroundColor = .liteWalletDarkBlue + return view + }() + + private let headerView: UIHostingController + private let pinPadViewController = PinPadViewController(style: .clear, keyboardType: .pinPad, maxDigits: 0) + private let pinViewContainer = UIView() + private var pinView: PinView? + private let isPresentedForLock: Bool + private let disabledView: WalletDisabledView + private let activityView = UIActivityIndicatorView(style: .large) + private let wipeBannerButton = UIButton() + + var delegate: LoginViewControllerDelegate? + + private var logo: UIImageView = { + let image = UIImageView(image: UIImage(named: "new-logotype-white")) + image.contentMode = .scaleAspectFit + image.alpha = 0.8 + return image + }() + + private let biometricsButton: UIButton = { + let button = UIButton(type: .system) + button.tintColor = .white + button.setImage(LAContext.biometricType() == .face ? #imageLiteral(resourceName: "FaceId") : #imageLiteral(resourceName: "TouchId"), for: .normal) + button.layer.borderColor = UIColor.white.cgColor + button.layer.borderWidth = 1.0 + button.layer.cornerRadius = squareButtonSize / 2.0 + button.layer.masksToBounds = true + button.accessibilityLabel = LAContext.biometricType() == .face ? S.UnlockScreen.faceIdText.localize() : S.UnlockScreen.touchIdText.localize() + return button + }() + + private let showLTCAddressButton: UIButton = { + let button = UIButton(type: .system) + button.tintColor = .white + button.setImage(#imageLiteral(resourceName: "genericqricon"), for: .normal) + button.layer.masksToBounds = true + return button + }() + + private let enterPINLabel = UILabel(font: .barlowSemiBold(size: 18), color: .white) + private var pinPadBottom: NSLayoutConstraint? + private var topControlTop: NSLayoutConstraint? + private var unlockTimer: Timer? + private var pinPadBackground = UIView() + private var hasAttemptedToShowBiometrics = false + private let lockedOverlay = UIVisualEffectView() + private var isResetting = false + private let versionLabel = UILabel(font: .barlowRegular(size: 12), color: .white) + private var isWalletEmpty = false + + override func viewDidLoad() { + checkWalletBalance() + addSubviews() + addConstraints() + addBiometricsButton() + + addPinPadCallback() + if pinView != nil { + addPinView() + } + addWipeWalletView() + disabledView.didTapReset = { [weak self] in + guard let store = self?.store else { return } + guard let walletManager = self?.walletManager else { return } + self?.isResetting = true + let nc = UINavigationController() + let recover = EnterPhraseViewController(store: store, walletManager: walletManager, reason: .validateForResettingPin + { phrase in + let updatePin = UpdatePinViewController(store: store, walletManager: walletManager, type: .creationWithPhrase, showsBackButton: false, phrase: phrase) + nc.pushViewController(updatePin, animated: true) + updatePin.resetFromDisabledWillSucceed = { + self?.disabledView.isHidden = true + } + updatePin.resetFromDisabledSuccess = { + self?.authenticationSucceded() + LWAnalytics.logEventWithParameters(itemName: ._20200217_DUWP) + } + }) + recover.addCloseNavigationItem() + nc.viewControllers = [recover] + nc.navigationBar.tintColor = .darkText + nc.navigationBar.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: UIColor.darkText, + NSAttributedString.Key.font: UIFont.customBold(size: 17.0), + ] + nc.setClearNavbar() + nc.navigationBar.isTranslucent = false + nc.navigationBar.barTintColor = .whiteTint + nc.viewControllers = [recover] + self?.present(nc, animated: true, completion: nil) + } + store.subscribe(self, name: .loginFromSend, callback: { _ in + self.authenticationSucceded() + }) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard UIApplication.shared.applicationState != .background else { return } + if shouldUseBiometrics, !hasAttemptedToShowBiometrics, !isPresentedForLock { + hasAttemptedToShowBiometrics = true + biometricsTapped() + } + + addShowAddressButton() + + if !isResetting { + lockIfNeeded() + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + unlockTimer?.invalidate() + } + + private func addPinView() { + guard let pinView = pinView else { return } + pinViewContainer.addSubview(pinView) + + logo.constrain([ + logo.topAnchor.constraint(equalTo: view.centerYAnchor, constant: -100), + logo.centerXAnchor.constraint(equalTo: view.centerXAnchor), + logo.constraint(.height, constant: 45), + logo.constraint(.width, constant: 201), + ]) + enterPINLabel.constrain([ + enterPINLabel.topAnchor.constraint(equalTo: pinView.topAnchor, constant: -40), + enterPINLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + ]) + + pinView.constrain([ + pinView.centerYAnchor.constraint(equalTo: pinPadViewController.view.topAnchor, constant: -40), + pinView.centerXAnchor.constraint(equalTo: pinViewContainer.centerXAnchor), + pinView.widthAnchor.constraint(equalToConstant: pinView.width), + pinView.heightAnchor.constraint(equalToConstant: pinView.itemSize), + ]) + } + + private func addSubviews() { + view.addSubview(backgroundView) + view.addSubview(headerView.view) + view.addSubview(pinViewContainer) + view.addSubview(logo) + view.addSubview(versionLabel) + view.addSubview(enterPINLabel) + + pinPadBackground.backgroundColor = .clear + if walletManager != nil { + view.addSubview(pinPadBackground) + } else { + view.addSubview(activityView) + } + } + + private func addConstraints() { + backgroundView.constrain(toSuperviewEdges: nil) + headerView.view.constrain([ + headerView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + headerView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + headerView.view.topAnchor.constraint(equalTo: backgroundView.topAnchor), + headerView.view.heightAnchor.constraint(equalToConstant: headerHeight), + ]) + + if walletManager != nil { + addChildViewController(pinPadViewController, layout: { + pinPadBottom = pinPadViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -120) + pinPadViewController.view.constrain([ + pinPadViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + pinPadViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + pinPadBottom, + pinPadViewController.view.heightAnchor.constraint(equalToConstant: pinPadViewController.height), + ]) + }) + } + pinViewContainer.constrain(toSuperviewEdges: nil) + + versionLabel.constrain([ + versionLabel.constraint(.bottom, toView: view, constant: -15), + versionLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor), + versionLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor), + versionLabel.heightAnchor.constraint(equalToConstant: 24.0), + ]) + + if walletManager != nil { + pinPadBackground.constrain([ + pinPadBackground.leadingAnchor.constraint(equalTo: pinPadViewController.view.leadingAnchor), + pinPadBackground.trailingAnchor.constraint(equalTo: pinPadViewController.view.trailingAnchor), + pinPadBackground.topAnchor.constraint(equalTo: pinPadViewController.view.topAnchor), + pinPadBackground.bottomAnchor.constraint(equalTo: pinPadViewController.view.bottomAnchor), + ]) + } else { + activityView.constrain([ + activityView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + activityView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -20.0), + ]) + activityView.startAnimating() + } + + enterPINLabel.text = S.UnlockScreen.enterPIN.localize() + versionLabel.text = AppVersion.string + versionLabel.textAlignment = .center + } + + private func deviceTopConstraintConstant() -> CGFloat { + let screenHeight = E.screenHeight + var constant = 0.0 + if screenHeight <= 640 { + constant = 35 + } else if screenHeight > 640, screenHeight < 800 { + constant = 45 + } else { + constant = 55 + } + return C.padding[1] + CGFloat(constant) + } + + private func addWipeWalletView() { + view.addSubview(wipeBannerButton) + wipeBannerButton.translatesAutoresizingMaskIntoConstraints = true + wipeBannerButton.backgroundColor = .clear + wipeBannerButton.adjustsImageWhenHighlighted = true + + wipeBannerButton.constrain([ + wipeBannerButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -35), + wipeBannerButton.leadingAnchor.constraint(equalTo: view.leadingAnchor), + wipeBannerButton.trailingAnchor.constraint(equalTo: view.trailingAnchor), + wipeBannerButton.heightAnchor.constraint(equalToConstant: 60), + ]) + + wipeBannerButton.setTitle(S.WipeWallet.emptyWallet.localize(), for: .normal) + wipeBannerButton.setTitleColor(UIColor.white.withAlphaComponent(0.7), for: .normal) + wipeBannerButton.titleLabel?.font = .barlowSemiBold(size: 17) + wipeBannerButton.addTarget(self, action: #selector(wipeTapped), for: .touchUpInside) + } + + private func addBiometricsButton() { + guard shouldUseBiometrics else { return } + view.addSubview(biometricsButton) + biometricsButton.addTarget(self, action: #selector(biometricsTapped), for: .touchUpInside) + biometricsButton.constrain([ + biometricsButton.widthAnchor.constraint(equalToConstant: squareButtonSize), + biometricsButton.heightAnchor.constraint(equalToConstant: squareButtonSize), + biometricsButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + biometricsButton.topAnchor.constraint(equalTo: view.topAnchor, constant: headerHeight + C.padding[2]), + ]) + } + + private func addShowAddressButton() { + view.addSubview(showLTCAddressButton) + showLTCAddressButton.addTarget(self, action: #selector(showLTCAddress), for: .touchUpInside) + showLTCAddressButton.constrain([ + showLTCAddressButton.widthAnchor.constraint(equalToConstant: squareButtonSize), + showLTCAddressButton.heightAnchor.constraint(equalToConstant: squareButtonSize), + showLTCAddressButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + showLTCAddressButton.topAnchor.constraint(equalTo: view.topAnchor, constant: headerHeight + C.padding[2]), + ]) + } + + private func addPinPadCallback() { + pinPadViewController.ouputDidUpdate = { [weak self] pin in + guard let myself = self else { return } + guard let pinView = self?.pinView else { return } + let attemptLength = pin.utf8.count + pinView.fill(attemptLength) + self?.pinPadViewController.isAppendingDisabled = attemptLength < myself.store.state.pinLength ? false : true + if attemptLength == myself.store.state.pinLength { + self?.authenticate(pin: pin) + } + } + } + + private func checkWalletBalance() { + if let wallet = walletManager?.wallet { + if wallet.balance == 0 { + isWalletEmpty = true + } else { + isWalletEmpty = false + } + } + } + + private func authenticate(pin: String) { + guard let walletManager = walletManager else { return } + guard !E.isScreenshots else { return authenticationSucceded() } + guard walletManager.authenticate(pin: pin) else { return authenticationFailed() } + authenticationSucceded() + LWAnalytics.logEventWithParameters(itemName: ._20200217_DUWP) + } + + private func authenticationSucceded() { + saveEvent("login.success") + let label = UILabel(font: enterPINLabel.font) + label.textColor = .white + label.text = S.UnlockScreen.unlocked.localize() + let lock = UIImageView(image: #imageLiteral(resourceName: "unlock")) + lock.transform = .init(scaleX: 0.6, y: 0.6) + + if let _pinView = pinView { + enterPINLabel.removeFromSuperview() + _pinView.removeFromSuperview() + } + + view.addSubview(label) + view.addSubview(lock) + + label.constrain([ + label.bottomAnchor.constraint(equalTo: view.centerYAnchor, constant: -C.padding[1]), + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + ]) + lock.constrain([ + lock.topAnchor.constraint(equalTo: label.bottomAnchor, constant: C.padding[1]), + lock.centerXAnchor.constraint(equalTo: label.centerXAnchor), + ]) + view.layoutIfNeeded() + + logo.alpha = 0.0 + wipeBannerButton.alpha = 1.0 + + UIView.spring(0.6, delay: 0.4, animations: { + self.pinPadBottom?.constant = self.pinPadViewController.height + self.topControlTop?.constant = -100.0 + + lock.alpha = 0.0 + label.alpha = 0.0 + self.wipeBannerButton.alpha = 0.0 + self.enterPINLabel.alpha = 0.0 + self.pinView?.alpha = 0.0 + + self.view.layoutIfNeeded() + }) { _ in + self.delegate?.didUnlockLogin() + if self.shouldSelfDismiss { + self.dismiss(animated: true, completion: nil) + } + self.store.perform(action: LoginSuccess()) + self.store.trigger(name: .showStatusBar) + } + } + + private func authenticationFailed() { + saveEvent("login.failed") + guard let pinView = pinView else { return } + pinPadViewController.view.isUserInteractionEnabled = false + pinView.shake { [weak self] in + self?.pinPadViewController.view.isUserInteractionEnabled = true + } + pinPadViewController.clear() + DispatchQueue.main.asyncAfter(deadline: .now() + pinView.shakeDuration) { [weak self] in + pinView.fill(0) + self?.lockIfNeeded() + } + } + + private var shouldUseBiometrics: Bool { + guard let walletManager = walletManager else { return false } + return LAContext.canUseBiometrics && !walletManager.pinLoginRequired && store.state.isBiometricsEnabled + } + + @objc func biometricsTapped() { + guard !isWalletDisabled else { return } + walletManager?.authenticate(biometricsPrompt: S.UnlockScreen.touchIdPrompt.localize(), completion: { result in + if result == .success { + self.authenticationSucceded() + LWAnalytics.logEventWithParameters(itemName: ._20200217_DUWB) + } + }) + } + + @objc func showLTCAddress() { + guard !isWalletDisabled else { return } + store.perform(action: RootModalActions.Present(modal: .loginAddress)) + } + + @objc func wipeTapped() { + store.perform(action: RootModalActions.Present(modal: .wipeEmptyWallet)) + } + + private func lockIfNeeded() { + if let disabledUntil = walletManager?.walletDisabledUntil { + let now = Date().timeIntervalSince1970 + if disabledUntil > now { + saveEvent("login.locked") + let disabledUntilDate = Date(timeIntervalSince1970: disabledUntil) + let unlockInterval = disabledUntil - now + let df = DateFormatter() + df.setLocalizedDateFormatFromTemplate(unlockInterval > C.secondsInDay ? "h:mm:ss a MMM d, yyy" : "h:mm:ss a") + + disabledView.setTimeLabel(string: String(format: S.UnlockScreen.disabled.localize(), df.string(from: disabledUntilDate))) + + pinPadViewController.view.isUserInteractionEnabled = false + unlockTimer?.invalidate() + unlockTimer = Timer.scheduledTimer(timeInterval: unlockInterval, target: self, selector: #selector(LoginViewController.unlock), userInfo: nil, repeats: false) + + if disabledView.superview == nil { + view.addSubview(disabledView) + setNeedsStatusBarAppearanceUpdate() + disabledView.constrain(toSuperviewEdges: nil) + disabledView.show() + } + } else { + pinPadViewController.view.isUserInteractionEnabled = true + disabledView.hide { [weak self] in + self?.disabledView.removeFromSuperview() + self?.setNeedsStatusBarAppearanceUpdate() + } + } + } + } + + private var isWalletDisabled: Bool { + guard let walletManager = walletManager else { return false } + let now = Date().timeIntervalSince1970 + return walletManager.walletDisabledUntil > now + } + + @objc private func unlock() { + saveEvent("login.unlocked") + delegate?.didUnlockLogin() + enterPINLabel.pushNewText(S.UnlockScreen.enterPIN.localize()) + pinPadViewController.view.isUserInteractionEnabled = true + unlockTimer = nil + disabledView.hide { [weak self] in + self?.disabledView.removeFromSuperview() + self?.setNeedsStatusBarAppearanceUpdate() + } + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + if disabledView.superview == nil { + return .lightContent + } else { + return .default + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/ModalNavigationController.swift b/litewallet/src/ViewControllers/ModalNavigationController.swift new file mode 100644 index 000000000..d31ded1a1 --- /dev/null +++ b/litewallet/src/ViewControllers/ModalNavigationController.swift @@ -0,0 +1,8 @@ +import UIKit + +class ModalNavigationController: UINavigationController { + override var preferredStatusBarStyle: UIStatusBarStyle { + guard let vc = topViewController else { return .default } + return vc.preferredStatusBarStyle + } +} diff --git a/litewallet/src/ViewControllers/NodeSelectorViewController.swift b/litewallet/src/ViewControllers/NodeSelectorViewController.swift new file mode 100644 index 000000000..49fd952dc --- /dev/null +++ b/litewallet/src/ViewControllers/NodeSelectorViewController.swift @@ -0,0 +1,146 @@ +import BRCore +import UIKit + +class NodeSelectorViewController: UIViewController, Trackable { + let titleLabel = UILabel(font: .customBold(size: 26.0), color: .darkText) + private let nodeLabel = UILabel(font: .customBody(size: 14.0), color: .grayTextTint) + private let node = UILabel(font: .customBody(size: 14.0), color: .darkText) + private let statusLabel = UILabel(font: .customBody(size: 14.0), color: .grayTextTint) + private let status = UILabel(font: .customBody(size: 14.0), color: .darkText) + private let button: ShadowButton + private let walletManager: WalletManager + private var okAction: UIAlertAction? + private var timer: Timer? + private let leftJustPadding = C.padding[6] + + init(walletManager: WalletManager) { + self.walletManager = walletManager + if UserDefaults.customNodeIP == nil { + button = ShadowButton(title: S.NodeSelector.manualButton.localize(), type: .primary) + } else { + button = ShadowButton(title: S.NodeSelector.automaticButton.localize(), type: .primary) + } + super.init(nibName: nil, bundle: nil) + } + + override func viewDidLoad() { + addSubviews() + addConstraints() + setInitialData() + } + + private func addSubviews() { + view.addSubview(titleLabel) + view.addSubview(nodeLabel) + view.addSubview(node) + view.addSubview(statusLabel) + view.addSubview(status) + view.addSubview(button) + } + + private func addConstraints() { + titleLabel.pinTopLeft(padding: leftJustPadding) + nodeLabel.pinTopLeft(toView: titleLabel, topPadding: leftJustPadding) + node.pinTopLeft(toView: nodeLabel, topPadding: 0) + statusLabel.pinTopLeft(toView: node, topPadding: leftJustPadding) + status.pinTopLeft(toView: statusLabel, topPadding: 0) + button.constrain([ + button.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: leftJustPadding), + button.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -leftJustPadding), + button.topAnchor.constraint(equalTo: status.bottomAnchor, constant: leftJustPadding), + button.heightAnchor.constraint(equalToConstant: 44.0), + ]) + } + + private func setInitialData() { + view.backgroundColor = .whiteTint + titleLabel.text = S.NodeSelector.title.localize() + nodeLabel.text = S.NodeSelector.nodeLabel.localize() + statusLabel.text = S.NodeSelector.statusLabel.localize() + button.tap = strongify(self) { myself in + if UserDefaults.customNodeIP == nil { + myself.switchToManual() + } else { + myself.switchToAuto() + } + } + setStatusText() + timer = Timer.scheduledTimer(timeInterval: 0.25, target: self, selector: #selector(setStatusText), userInfo: nil, repeats: true) + } + + @objc private func setStatusText() { + if let peerManager = walletManager.peerManager { + status.text = peerManager.isConnected ? S.NodeSelector.connected.localize() : S.NodeSelector.notConnected.localize() + } + node.text = walletManager.peerManager?.downloadPeerName + } + + private func switchToAuto() { + guard UserDefaults.customNodeIP != nil else { return } // noop if custom node is already nil + saveEvent("nodeSelector.switchToAuto") + UserDefaults.customNodeIP = nil + UserDefaults.customNodePort = nil + button.title = S.NodeSelector.manualButton.localize() + DispatchQueue.walletQueue.async { + self.walletManager.peerManager?.setFixedPeer(address: 0, port: 0) + self.walletManager.peerManager?.connect() + } + } + + private func switchToManual() { + let alert = UIAlertController(title: S.NodeSelector.enterTitle.localize(), message: S.NodeSelector.enterBody.localize(), preferredStyle: .alert) + alert.addAction(UIAlertAction(title: S.Button.cancel.localize(), style: .cancel, handler: nil)) + let okAction = UIAlertAction(title: S.Button.ok.localize(), style: .default, handler: { _ in + guard let ip = alert.textFields?.first, let port = alert.textFields?.last else { return } + if let addressText = ip.text { + self.saveEvent("nodeSelector.switchToManual") + var address = in_addr() + ascii2addr(AF_INET, addressText, &address) + UserDefaults.customNodeIP = Int(address.s_addr) + if let portText = port.text { + UserDefaults.customNodePort = Int(portText) + } + DispatchQueue.walletQueue.async { + self.walletManager.peerManager?.connect() + } + self.button.title = S.NodeSelector.automaticButton.localize() + } + }) + self.okAction = okAction + self.okAction?.isEnabled = false + alert.addAction(okAction) + alert.addTextField { textField in + textField.placeholder = "192.168.0.1" + textField.keyboardType = .decimalPad + textField.addTarget(self, action: #selector(self.ipAddressDidChange(textField:)), for: .editingChanged) + } + alert.addTextField { textField in + textField.placeholder = "9333" + textField.keyboardType = .decimalPad + } + present(alert, animated: true, completion: nil) + } + + private func setCustomNodeText() { + if var customNode = UserDefaults.customNodeIP { + if let buf = addr2ascii(AF_INET, &customNode, Int32(MemoryLayout.size), nil) { + node.text = String(cString: buf) + } + } + } + + @objc private func ipAddressDidChange(textField: UITextField) { + if let text = textField.text { + if text.components(separatedBy: ".").count == 4, ascii2addr(AF_INET, text, nil) > 0 { + okAction?.isEnabled = true + return + } + } + okAction?.isEnabled = false + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/PinPadViewController.swift b/litewallet/src/ViewControllers/PinPadViewController.swift new file mode 100644 index 000000000..786400383 --- /dev/null +++ b/litewallet/src/ViewControllers/PinPadViewController.swift @@ -0,0 +1,407 @@ +import UIKit + +enum PinPadColorStyle { + case white + case clear +} + +enum KeyboardType { + case decimalPad + case pinPad +} + +let deleteKeyIdentifier = "del" +let kDecimalPadItemHeight: CGFloat = 48.0 +let kPinPadItemHeight: CGFloat = 54.0 + +class PinPadViewController: UICollectionViewController { + let currencyDecimalSeparator = NumberFormatter().currencyDecimalSeparator ?? "." + var isAppendingDisabled = false + var ouputDidUpdate: ((String) -> Void)? + var didUpdateFrameWidth: ((CGRect) -> Void)? + + var height: CGFloat { + switch keyboardType { + case .decimalPad: + return kDecimalPadItemHeight * 4.0 // for four rows tall + case .pinPad: + return kPinPadItemHeight * 4.0 // for four rows tall + } + } + + var currentOutput = "" + + func clear() { + isAppendingDisabled = false + currentOutput = "" + } + + func removeLast() { + if !currentOutput.utf8.isEmpty { + currentOutput = String(currentOutput[.. Int { + return items.count + } + + override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell + { + let item = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath) + guard let pinPadCell = item as? GenericPinPadCell else { return item } + pinPadCell.text = items[indexPath.item] + + // produces a frame for lining up other subviews + if indexPath.item == 0 { + didUpdateFrameWidth?(collectionView.convert(pinPadCell.frame, to: view)) + } + return pinPadCell + } + + // MARK: - UICollectionViewDelegate + + override func collectionView(_: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let item = items[indexPath.row] + if item == "del" { + if !currentOutput.isEmpty { + if currentOutput == ("0" + currencyDecimalSeparator) { + currentOutput = "" + } else { + currentOutput.remove(at: currentOutput.index(before: currentOutput.endIndex)) + } + } + } else { + if shouldAppendChar(char: item), !isAppendingDisabled { + currentOutput = currentOutput + item + } + } + ouputDidUpdate?(currentOutput) + } + + func shouldAppendChar(char: String) -> Bool { + let decimalLocation = currentOutput.range(of: currencyDecimalSeparator)?.lowerBound + + // Don't allow more that maxDigits decimal points + if let location = decimalLocation { + let locationValue = currentOutput.distance(from: currentOutput.endIndex, to: location) + if locationValue < -maxDigits { + return false + } + } + + // Don't allow more than 2 decimal separators + if currentOutput.contains("\(currencyDecimalSeparator)"), char == currencyDecimalSeparator { + return false + } + + if keyboardType == .decimalPad { + if currentOutput == "0" { + // Append . to 0 + if char == currencyDecimalSeparator { + return true + + // Dont append 0 to 0 + } else if char == "0" { + return false + + // Replace 0 with any other digit + } else { + currentOutput = char + return false + } + } + } + + if char == currencyDecimalSeparator { + if decimalLocation == nil { + // Prepend a 0 if the first character is a decimal point + if currentOutput.isEmpty { + currentOutput = "0" + } + return true + } else { + return false + } + } + return true + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class GenericPinPadCell: UICollectionViewCell { + var text: String? { + didSet { + if text == deleteKeyIdentifier { + imageView.image = #imageLiteral(resourceName: "Delete") + topLabel.text = "" + centerLabel.text = "" + } else { + imageView.image = nil + topLabel.text = text + centerLabel.text = text + } + setAppearance() + setSublabel() + } + } + + let sublabels = [ + "2": "ABC", + "3": "DEF", + "4": "GHI", + "5": "JKL", + "6": "MNO", + "7": "PORS", + "8": "TUV", + "9": "WXYZ", + ] + + override var isHighlighted: Bool { + didSet { + guard text != "" else { return } // We don't want the blank cell to highlight + setAppearance() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + internal let topLabel = UILabel(font: .customBody(size: 28.0)) + internal let centerLabel = UILabel(font: .customBody(size: 28.0)) + internal let sublabel = UILabel(font: .customBody(size: 11.0)) + internal let imageView = UIImageView() + + private func setup() { + setAppearance() + topLabel.textAlignment = .center + centerLabel.textAlignment = .center + sublabel.textAlignment = .center + addSubview(topLabel) + addSubview(centerLabel) + addSubview(sublabel) + addSubview(imageView) + imageView.contentMode = .center + addConstraints() + } + + func addConstraints() { + imageView.constrain(toSuperviewEdges: nil) + centerLabel.constrain(toSuperviewEdges: nil) + topLabel.constrain([ + topLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + topLabel.topAnchor.constraint(equalTo: topAnchor, constant: 2.5), + ]) + sublabel.constrain([ + sublabel.centerXAnchor.constraint(equalTo: centerXAnchor), + sublabel.topAnchor.constraint(equalTo: topLabel.bottomAnchor, constant: -3.0), + ]) + } + + override var isAccessibilityElement: Bool { + get { + return true + } + set {} + } + + override var accessibilityLabel: String? { + get { + return topLabel.text + } + set {} + } + + override var accessibilityTraits: UIAccessibilityTraits { + get { + return UIAccessibilityTraits.staticText + } + set {} + } + + func setAppearance() {} + func setSublabel() {} + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class ClearNumberPad: GenericPinPadCell { + override func setAppearance() { + if text == "0" { + topLabel.isHidden = true + centerLabel.isHidden = false + } else { + topLabel.isHidden = false + centerLabel.isHidden = true + } + + topLabel.textColor = .white + centerLabel.textColor = .white + sublabel.textColor = .white + + if isHighlighted { + backgroundColor = .transparentBlack + } else { + backgroundColor = .clear + + if text == "" || text == deleteKeyIdentifier { + imageView.image = imageView.image?.withRenderingMode(.alwaysTemplate) + imageView.tintColor = .white + } + } + } + + override func setSublabel() { + guard let text = text else { return } + if sublabels[text] != nil { + sublabel.text = sublabels[text] + } + } +} + +class ClearDecimalPad: GenericPinPadCell { + override func setAppearance() { + centerLabel.backgroundColor = .clear + imageView.image = imageView.image?.withRenderingMode(.alwaysTemplate) + + if isHighlighted { + centerLabel.textColor = .grayTextTint + imageView.tintColor = .grayTextTint + } else { + centerLabel.textColor = .white + imageView.tintColor = .white + } + } + + override func addConstraints() { + centerLabel.constrain(toSuperviewEdges: nil) + imageView.constrain(toSuperviewEdges: nil) + } +} + +class WhiteDecimalPad: GenericPinPadCell { + override func setAppearance() { + if isHighlighted { + centerLabel.backgroundColor = .secondaryShadow + centerLabel.textColor = .darkText + } else { + centerLabel.backgroundColor = .white + centerLabel.textColor = .grayTextTint + } + } + + override func addConstraints() { + centerLabel.constrain(toSuperviewEdges: nil) + imageView.constrain(toSuperviewEdges: nil) + } +} + +class WhiteNumberPad: GenericPinPadCell { + override func setAppearance() { + if text == "0" { + topLabel.isHidden = true + centerLabel.isHidden = false + } else { + topLabel.isHidden = false + centerLabel.isHidden = true + } + + if isHighlighted { + backgroundColor = .secondaryShadow + topLabel.textColor = .darkText + centerLabel.textColor = .darkText + sublabel.textColor = .darkText + } else { + if text == "" || text == deleteKeyIdentifier { + backgroundColor = .whiteTint + imageView.image = imageView.image?.withRenderingMode(.alwaysTemplate) + imageView.tintColor = .grayTextTint + } else { + backgroundColor = .whiteTint + topLabel.textColor = .grayTextTint + centerLabel.textColor = .grayTextTint + sublabel.textColor = .grayTextTint + } + } + } + + override func setSublabel() { + guard let text = text else { return } + if sublabels[text] != nil { + sublabel.text = sublabels[text] + } + } +} diff --git a/litewallet/src/ViewControllers/ReScanViewController.swift b/litewallet/src/ViewControllers/ReScanViewController.swift new file mode 100644 index 000000000..caef3efd1 --- /dev/null +++ b/litewallet/src/ViewControllers/ReScanViewController.swift @@ -0,0 +1,104 @@ +import UIKit + +class ReScanViewController: UIViewController, Subscriber { + init(store: Store) { + self.store = store + faq = .buildFaqButton(store: store, articleId: ArticleIds.nothing) + super.init(nibName: nil, bundle: nil) + } + + private let header = UILabel(font: .customBold(size: 26.0), color: .darkText) + private let body = UILabel.wrapping(font: .systemFont(ofSize: 15.0)) + private let button = ShadowButton(title: S.ReScan.buttonTitle.localize(), type: .primary) + private let footer = UILabel.wrapping(font: .customBody(size: 16.0), color: .secondaryGrayText) + private let store: Store + private let faq: UIButton + + deinit { + store.unsubscribe(self) + } + + override func viewDidLoad() { + addSubviews() + addConstraints() + setInitialData() + } + + private func addSubviews() { + view.addSubview(header) + view.addSubview(faq) + view.addSubview(body) + view.addSubview(button) + view.addSubview(footer) + } + + private func addConstraints() { + header.constrain([ + header.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + header.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: C.padding[2]), + ]) + faq.constrain([ + faq.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + faq.centerYAnchor.constraint(equalTo: header.centerYAnchor), + faq.widthAnchor.constraint(equalToConstant: 44.0), + faq.heightAnchor.constraint(equalToConstant: 44.0), + ]) + body.constrain([ + body.leadingAnchor.constraint(equalTo: header.leadingAnchor), + body.topAnchor.constraint(equalTo: header.bottomAnchor, constant: C.padding[2]), + body.trailingAnchor.constraint(equalTo: faq.trailingAnchor), + ]) + footer.constrain([ + footer.leadingAnchor.constraint(equalTo: header.leadingAnchor), + footer.trailingAnchor.constraint(equalTo: faq.trailingAnchor), + footer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -C.padding[3]), + ]) + button.constrain([ + button.leadingAnchor.constraint(equalTo: footer.leadingAnchor), + button.trailingAnchor.constraint(equalTo: footer.trailingAnchor), + button.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -C.padding[2]), + button.heightAnchor.constraint(equalToConstant: C.Sizes.buttonHeight), + ]) + } + + private func setInitialData() { + view.backgroundColor = .whiteTint + header.text = S.ReScan.header.localize() + body.attributedText = bodyText + footer.text = S.ReScan.footer.localize() + button.tap = { [weak self] in + self?.presentRescanAlert() + } + } + + private func presentRescanAlert() { + let alert = UIAlertController(title: S.ReScan.alertTitle.localize(), message: S.ReScan.alertMessage.localize(), preferredStyle: .alert) + alert.addAction(UIAlertAction(title: S.Button.cancel.localize(), style: .default, handler: nil)) + alert.addAction(UIAlertAction(title: S.ReScan.alertAction.localize(), style: .default, handler: { _ in + self.store.trigger(name: .rescan) + LWAnalytics.logEventWithParameters(itemName: ._20200112_DSR) + + self.dismiss(animated: true, completion: nil) + })) + present(alert, animated: true, completion: nil) + } + + private var bodyText: NSAttributedString { + let body = NSMutableAttributedString() + let headerAttributes = [NSAttributedString.Key.font: UIFont.customBold(size: 16.0), + NSAttributedString.Key.foregroundColor: UIColor.darkText] + let bodyAttributes = [NSAttributedString.Key.font: UIFont.customBody(size: 16.0), + NSAttributedString.Key.foregroundColor: UIColor.darkText] + + body.append(NSAttributedString(string: "\(S.ReScan.subheader1)\n", attributes: headerAttributes)) + body.append(NSAttributedString(string: "\(S.ReScan.body1)\n\n", attributes: bodyAttributes)) + body.append(NSAttributedString(string: "\(S.ReScan.subheader2)\n", attributes: headerAttributes)) + body.append(NSAttributedString(string: "\(S.ReScan.body2)\n\n\(S.ReScan.body3)", attributes: bodyAttributes)) + return body + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/RecoverWalletIntroViewController.swift b/litewallet/src/ViewControllers/RecoverWalletIntroViewController.swift new file mode 100644 index 000000000..bc0c6867e --- /dev/null +++ b/litewallet/src/ViewControllers/RecoverWalletIntroViewController.swift @@ -0,0 +1,69 @@ +import UIKit + +class RecoverWalletIntroViewController: UIViewController { + // MARK: - Public + + init(didTapNext: @escaping () -> Void) { + self.didTapNext = didTapNext + super.init(nibName: nil, bundle: nil) + } + + // MARK: - Private + + private let didTapNext: () -> Void + private let header = RadialGradientView(backgroundColor: .purple) + private let nextButton = ShadowButton(title: S.RecoverWallet.next.localize(), type: .primary) + private let label = UILabel(font: .customBody(size: 16.0)) + private let illustration = UIImageView(image: #imageLiteral(resourceName: "RecoverWalletIllustration")) + + override func viewDidLoad() { + addSubviews() + addConstraints() + setData() + } + + private func addSubviews() { + view.addSubview(header) + header.addSubview(illustration) + view.addSubview(nextButton) + view.addSubview(label) + } + + private func addConstraints() { + header.constrainTopCorners(sidePadding: 0.0, topPadding: 0.0) + header.constrain([header.heightAnchor.constraint(equalToConstant: C.Sizes.largeHeaderHeight)]) + illustration.constrain([ + illustration.centerXAnchor.constraint(equalTo: header.centerXAnchor), + illustration.centerYAnchor.constraint(equalTo: header.centerYAnchor, constant: C.padding[2]), + ]) + label.constrain([ + label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + label.topAnchor.constraint(equalTo: header.bottomAnchor, constant: C.padding[2]), + label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + ]) + nextButton.constrain([ + nextButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + nextButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -C.padding[3]), + nextButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + nextButton.heightAnchor.constraint(equalToConstant: C.Sizes.buttonHeight), + ]) + } + + private func setData() { + view.backgroundColor = .white + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.text = S.RecoverWallet.intro.localize() + nextButton.tap = didTapNext + title = S.RecoverWallet.header.localize() + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/RequestAmountViewController.swift b/litewallet/src/ViewControllers/RequestAmountViewController.swift new file mode 100644 index 000000000..7966efee6 --- /dev/null +++ b/litewallet/src/ViewControllers/RequestAmountViewController.swift @@ -0,0 +1,250 @@ +import UIKit + +private let qrSize: CGSize = .init(width: 186.0, height: 186.0) +private let smallButtonHeight: CGFloat = 32.0 +private let buttonPadding: CGFloat = 20.0 +private let smallSharePadding: CGFloat = 12.0 +private let largeSharePadding: CGFloat = 20.0 + +class RequestAmountViewController: UIViewController { + var presentEmail: PresentShare? + var presentText: PresentShare? + + init(wallet: BRWallet, store: Store) { + self.wallet = wallet + amountView = AmountViewController(store: store, + isPinPadExpandedAtLaunch: true, + hasAcceptedFees: false, + isRequesting: true) + super.init(nibName: nil, bundle: nil) + } + + // MARK: - Private + + private let amountView: AmountViewController + private let qrCode = UIImageView() + private let address = UILabel(font: .customBody(size: 14.0)) + private let addressPopout = InViewAlert(type: .primary) + private let share = ShadowButton(title: S.Receive.share.localize(), type: .tertiary, image: #imageLiteral(resourceName: "Share")) + private let sharePopout = InViewAlert(type: .secondary) + private let border = UIView() + private var topSharePopoutConstraint: NSLayoutConstraint? + private let wallet: BRWallet + + // MARK: - PinPad State + + private var amount: Satoshis? { + didSet { + setQrCode() + } + } + + override func viewDidLoad() { + addSubviews() + addConstraints() + setData() + addActions() + setupCopiedMessage() + setupShareButtons() + } + + private func addSubviews() { + view.addSubview(qrCode) + view.addSubview(address) + view.addSubview(addressPopout) + view.addSubview(share) + view.addSubview(sharePopout) + view.addSubview(border) + } + + private func addConstraints() { + addChildViewController(amountView, layout: { + amountView.view.constrain([ + amountView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + amountView.view.topAnchor.constraint(equalTo: view.topAnchor), + amountView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + }) + qrCode.constrain([ + qrCode.constraint(.width, constant: qrSize.width), + qrCode.constraint(.height, constant: qrSize.height), + qrCode.topAnchor.constraint(equalTo: amountView.view.bottomAnchor, constant: C.padding[2]), + qrCode.constraint(.centerX, toView: view), + ]) + address.constrain([ + address.constraint(toBottom: qrCode, constant: C.padding[1]), + address.constraint(.centerX, toView: view), + ]) + addressPopout.heightConstraint = addressPopout.constraint(.height, constant: 0.0) + addressPopout.constrain([ + addressPopout.constraint(toBottom: address, constant: 0.0), + addressPopout.constraint(.centerX, toView: view), + addressPopout.constraint(.width, toView: view), + addressPopout.heightConstraint, + ]) + share.constrain([ + share.constraint(toBottom: addressPopout, constant: C.padding[2]), + share.constraint(.centerX, toView: view), + share.constraint(.width, constant: qrSize.width), + share.constraint(.height, constant: smallButtonHeight), + ]) + sharePopout.heightConstraint = sharePopout.constraint(.height, constant: 0.0) + topSharePopoutConstraint = sharePopout.constraint(toBottom: share, constant: largeSharePadding) + sharePopout.constrain([ + topSharePopoutConstraint, + sharePopout.constraint(.centerX, toView: view), + sharePopout.constraint(.width, toView: view), + sharePopout.heightConstraint, + ]) + border.constrain([ + border.constraint(.width, toView: view), + border.constraint(toBottom: sharePopout, constant: 0.0), + border.constraint(.centerX, toView: view), + border.constraint(.height, constant: 1.0), + border.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -C.padding[2]), + ]) + } + + private func setData() { + view.backgroundColor = .white + address.text = wallet.receiveAddress + address.textColor = .grayTextTint + border.backgroundColor = .secondaryBorder + qrCode.image = UIImage.qrCode(data: "\(wallet.receiveAddress)".data(using: .utf8)!, color: CIColor(color: .black))? + .resize(qrSize)! + share.isToggleable = true + sharePopout.clipsToBounds = true + } + + private func addActions() { + let gr = UITapGestureRecognizer(target: self, action: #selector(RequestAmountViewController.addressTapped)) + address.addGestureRecognizer(gr) + address.isUserInteractionEnabled = true + share.addTarget(self, action: #selector(RequestAmountViewController.shareTapped), for: .touchUpInside) + amountView.didUpdateAmount = { [weak self] amount in + self?.amount = amount + } + } + + private func setQrCode() { + guard let amount = amount else { return } + let request = PaymentRequest.requestString(withAddress: wallet.receiveAddress, forAmount: amount.rawValue) + qrCode.image = UIImage.qrCode(data: request.data(using: .utf8)!, color: CIColor(color: .black))? + .resize(qrSize)! + } + + private func setupCopiedMessage() { + let copiedMessage = UILabel(font: .customMedium(size: 14.0)) + copiedMessage.textColor = .white + copiedMessage.text = S.Receive.copied.localize() + copiedMessage.textAlignment = .center + addressPopout.contentView = copiedMessage + } + + private func setupShareButtons() { + let container = UIView() + container.translatesAutoresizingMaskIntoConstraints = false + let email = ShadowButton(title: S.Receive.emailButton.localize(), type: .tertiary) + let text = ShadowButton(title: S.Receive.textButton.localize(), type: .tertiary) + container.addSubview(email) + container.addSubview(text) + email.constrain([ + email.constraint(.leading, toView: container, constant: C.padding[2]), + email.constraint(.top, toView: container, constant: buttonPadding), + email.constraint(.bottom, toView: container, constant: -buttonPadding), + email.trailingAnchor.constraint(equalTo: container.centerXAnchor, constant: -C.padding[1]), + ]) + text.constrain([ + text.constraint(.trailing, toView: container, constant: -C.padding[2]), + text.constraint(.top, toView: container, constant: buttonPadding), + text.constraint(.bottom, toView: container, constant: -buttonPadding), + text.leadingAnchor.constraint(equalTo: container.centerXAnchor, constant: C.padding[1]), + ]) + sharePopout.contentView = container + email.addTarget(self, action: #selector(RequestAmountViewController.emailTapped), for: .touchUpInside) + text.addTarget(self, action: #selector(RequestAmountViewController.textTapped), for: .touchUpInside) + } + + @objc private func shareTapped() { + toggle(alertView: sharePopout, shouldAdjustPadding: true) + if addressPopout.isExpanded { + toggle(alertView: addressPopout, shouldAdjustPadding: false) + } + } + + @objc private func addressTapped() { + guard let text = address.text else { return } + UIPasteboard.general.string = text + toggle(alertView: addressPopout, shouldAdjustPadding: false, shouldShrinkAfter: true) + if sharePopout.isExpanded { + toggle(alertView: sharePopout, shouldAdjustPadding: true) + } + } + + @objc private func emailTapped() { + guard let amount = amount else { return showErrorMessage(S.RequestAnAmount.noAmount.localize()) } + let text = PaymentRequest.requestString(withAddress: wallet.receiveAddress, forAmount: amount.rawValue) + presentEmail?(text, qrCode.image!) + } + + @objc private func textTapped() { + guard let amount = amount else { return showErrorMessage(S.RequestAnAmount.noAmount.localize()) } + let text = PaymentRequest.requestString(withAddress: wallet.receiveAddress, forAmount: amount.rawValue) + presentText?(text, qrCode.image!) + } + + private func toggle(alertView: InViewAlert, shouldAdjustPadding: Bool, shouldShrinkAfter: Bool = false) + { + share.isEnabled = false + address.isUserInteractionEnabled = false + + var deltaY = alertView.isExpanded ? -alertView.height : alertView.height + if shouldAdjustPadding { + if deltaY > 0 { + deltaY -= (largeSharePadding - smallSharePadding) + } else { + deltaY += (largeSharePadding - smallSharePadding) + } + } + + if alertView.isExpanded { + alertView.contentView?.isHidden = true + } + + UIView.spring(C.animationDuration, animations: { + if shouldAdjustPadding { + let newPadding = self.sharePopout.isExpanded ? largeSharePadding : smallSharePadding + self.topSharePopoutConstraint?.constant = newPadding + } + alertView.toggle() + self.parent?.view.layoutIfNeeded() + }, completion: { _ in + alertView.isExpanded = !alertView.isExpanded + self.share.isEnabled = true + self.address.isUserInteractionEnabled = true + alertView.contentView?.isHidden = false + if shouldShrinkAfter { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + if alertView.isExpanded { + self.toggle(alertView: alertView, shouldAdjustPadding: shouldAdjustPadding) + } + } + } + }) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension RequestAmountViewController: ModalDisplayable { + var faqArticleId: String? { + return ArticleIds.nothing + } + + var modalTitle: String { + return S.Receive.request.localize() + } +} diff --git a/litewallet/src/ViewControllers/RootModals/ManageWalletViewController.swift b/litewallet/src/ViewControllers/RootModals/ManageWalletViewController.swift new file mode 100644 index 000000000..b47dee2b4 --- /dev/null +++ b/litewallet/src/ViewControllers/RootModals/ManageWalletViewController.swift @@ -0,0 +1,147 @@ +import UIKit + +class ManageWalletViewController: UIViewController, ModalPresentable, Subscriber { + var parentView: UIView? // ModalPresentable + private let textFieldLabel = UILabel(font: .customBold(size: 14.0), color: .grayTextTint) + private let textField = UITextField() + private let separator = UIView(color: .secondaryShadow) + fileprivate let body = UILabel.wrapping(font: .customBody(size: 13.0), color: .secondaryGrayText) + private let store: Store + fileprivate let maxWalletNameLength = 20 + + init(store: Store) { + self.store = store + super.init(nibName: nil, bundle: nil) + } + + override func viewDidLoad() { + addSubviews() + addConstraints() + setData() + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(notification:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + saveWalletName() + } + + deinit { + NotificationCenter.default.removeObserver(self) + store.unsubscribe(self) + } + + private func addSubviews() { + view.addSubview(textFieldLabel) + view.addSubview(textField) + view.addSubview(separator) + view.addSubview(body) + } + + private func addConstraints() { + textFieldLabel.constrain([ + textFieldLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + textFieldLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: C.padding[2]), + textFieldLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + ]) + textField.constrain([ + textField.leadingAnchor.constraint(equalTo: textFieldLabel.leadingAnchor), + textField.topAnchor.constraint(equalTo: textFieldLabel.bottomAnchor), + textField.trailingAnchor.constraint(equalTo: textFieldLabel.trailingAnchor), + ]) + separator.constrain([ + separator.leadingAnchor.constraint(equalTo: textField.leadingAnchor), + separator.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: C.padding[2]), + separator.trailingAnchor.constraint(equalTo: textField.trailingAnchor), + separator.heightAnchor.constraint(equalToConstant: 1.0), + ]) + body.constrain([ + body.leadingAnchor.constraint(equalTo: separator.leadingAnchor), + body.topAnchor.constraint(equalTo: separator.bottomAnchor, constant: C.padding[2]), + body.trailingAnchor.constraint(equalTo: separator.trailingAnchor), + body.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -C.padding[2]), + ]) + } + + private func setData() { + view.backgroundColor = .white + textField.textColor = .darkText + textField.font = .customBody(size: 14.0) + textField.returnKeyType = .done + textFieldLabel.text = S.ManageWallet.textFieldLabel.localize() + textField.delegate = self + + textField.text = store.state.walletState.name + let creationDate = store.state.walletState.creationDate + if creationDate.timeIntervalSince1970 > 0 { + let df = DateFormatter() + df.dateFormat = "MMMM d, yyyy" + body.text = "\(S.ManageWallet.description.localize())\n\n\(S.ManageWallet.creationDatePrefix.localize()) \(df.string(from: creationDate))" + } else { + body.text = S.ManageWallet.description.localize() + } + } + + // MARK: - Keyboard Notifications + + @objc private func keyboardWillShow(notification: Notification) { + copyKeyboardChangeAnimation(notification: notification) + } + + @objc private func keyboardWillHide(notification: Notification) { + copyKeyboardChangeAnimation(notification: notification) + } + + private func copyKeyboardChangeAnimation(notification: Notification) { + guard let info = KeyboardNotificationInfo(notification.userInfo) else { return } + UIView.animate(withDuration: info.animationDuration, delay: 0, options: info.animationOptions, animations: { + guard let parentView = self.parentView else { return } + parentView.frame = parentView.frame.offsetBy(dx: 0, dy: info.deltaY) + }, completion: nil) + } + + func saveWalletName() { + guard var name = textField.text else { return } + if name.utf8.count > maxWalletNameLength { + name = String(name[.. Bool { + textField.resignFirstResponder() + return true + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn _: NSRange, replacementString string: String) -> Bool + { + guard let text = textField.text else { return true } + if text.utf8.count + string.utf8.count > maxWalletNameLength { + return false + } else { + return true + } + } +} + +extension ManageWalletViewController: ModalDisplayable { + var faqArticleId: String? { + return ArticleIds.nothing + } + + var modalTitle: String { + return S.ManageWallet.title.localize() + } +} diff --git a/litewallet/src/ViewControllers/RootModals/MenuViewController.swift b/litewallet/src/ViewControllers/RootModals/MenuViewController.swift new file mode 100644 index 000000000..cc6fa9a7e --- /dev/null +++ b/litewallet/src/ViewControllers/RootModals/MenuViewController.swift @@ -0,0 +1,76 @@ +import UIKit + +class MenuViewController: UIViewController, Trackable { + // MARK: - Public + + var didTapSecurity: (() -> Void)? + var didTapSupport: (() -> Void)? + var didTapSettings: (() -> Void)? + var didTapLock: (() -> Void)? + + // MARK: - Private + + fileprivate let buttonHeight: CGFloat = 72.0 + fileprivate let buttons: [MenuButton] = { + let types: [MenuButtonType] = [.security, .customerSupport, .settings, .lock] + return types.compactMap { + return MenuButton(type: $0) + } + }() + + fileprivate let bottomPadding: CGFloat = 32.0 + + override func viewDidLoad() { + var previousButton: UIView? + buttons.forEach { button in + button.addTarget(self, action: #selector(MenuViewController.didTapButton(button:)), for: .touchUpInside) + view.addSubview(button) + var topConstraint: NSLayoutConstraint? + if let viewAbove = previousButton { + topConstraint = button.constraint(toBottom: viewAbove, constant: 0.0) + } else { + topConstraint = button.constraint(.top, toView: view, constant: 0.0) + } + button.constrain([ + topConstraint, + button.constraint(.leading, toView: view, constant: 0.0), + button.constraint(.trailing, toView: view, constant: 0.0), + button.constraint(.height, constant: buttonHeight), + ]) + previousButton = button + } + + previousButton?.constrain([ + previousButton?.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -C.padding[2]), + ]) + + if #available(iOS 11.0, *) { + view.backgroundColor = UIColor(named: "lfBackgroundColor") + } else { + view.backgroundColor = .white + } + } + + @objc private func didTapButton(button: MenuButton) { + switch button.type { + case .security: + didTapSecurity?() + case .customerSupport: + didTapSupport?() + case .settings: + didTapSettings?() + case .lock: + didTapLock?() + } + } +} + +extension MenuViewController: ModalDisplayable { + var faqArticleId: String? { + return nil + } + + var modalTitle: String { + return S.MenuViewController.modalTitle.localize() + } +} diff --git a/litewallet/src/ViewControllers/RootModals/ModalDisplayable.swift b/litewallet/src/ViewControllers/RootModals/ModalDisplayable.swift new file mode 100644 index 000000000..b3f553c6a --- /dev/null +++ b/litewallet/src/ViewControllers/RootModals/ModalDisplayable.swift @@ -0,0 +1,10 @@ +import UIKit + +protocol ModalDisplayable { + var modalTitle: String { get } + var faqArticleId: String? { get } +} + +protocol ModalPresentable { + var parentView: UIView? { get set } +} diff --git a/litewallet/src/ViewControllers/RootModals/ModalViewController.swift b/litewallet/src/ViewControllers/RootModals/ModalViewController.swift new file mode 100644 index 000000000..01a6dcd13 --- /dev/null +++ b/litewallet/src/ViewControllers/RootModals/ModalViewController.swift @@ -0,0 +1,159 @@ +import UIKit + +class ModalViewController: UIViewController, Subscriber { + // MARK: - Public + + var childViewController: UIViewController + + init(childViewController: T, store: Store) where T: ModalDisplayable { + self.childViewController = childViewController + modalInfo = childViewController + self.store = store + header = ModalHeaderView(title: modalInfo.modalTitle, style: .dark) + super.init(nibName: nil, bundle: nil) + } + + // MARK: - Private + + private let modalInfo: ModalDisplayable + private let headerHeight: CGFloat = 49.0 + fileprivate let header: ModalHeaderView + private let tapGestureRecognizer = UITapGestureRecognizer() + private let store: Store + private let scrollView = UIScrollView() + private let scrollViewContent = UIView() + + deinit { + store.unsubscribe(self) + } + + override func viewDidLoad() { + addSubviews() + addConstraints() + setInitialData() + } + + private func addSubviews() { + view.addSubview(header) + view.addSubview(scrollView) + scrollView.addSubview(scrollViewContent) + + addChild(childViewController) + scrollViewContent.addSubview(childViewController.view) + childViewController.didMove(toParent: self) + } + + private func addConstraints() { + header.constrain([ + header.leadingAnchor.constraint(equalTo: view.leadingAnchor), + header.bottomAnchor.constraint(equalTo: scrollView.topAnchor), + header.trailingAnchor.constraint(equalTo: view.trailingAnchor), + header.heightAnchor.constraint(equalToConstant: headerHeight), + ]) + scrollView.constrain([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + scrollViewContent.constrain([ + scrollViewContent.topAnchor.constraint(equalTo: scrollView.topAnchor), + scrollViewContent.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + scrollViewContent.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + scrollViewContent.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + scrollViewContent.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + ]) + + childViewController.view.constrain(toSuperviewEdges: nil) + + // Two stage layout is required here because we need the height constant + // of the content at initial layout + view.layoutIfNeeded() + let height = scrollViewContent.bounds.size.height + 60.0 + let minHeight = scrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: height) + let midHeight = scrollView.heightAnchor.constraint(equalTo: scrollViewContent.heightAnchor) + let maxHeight = scrollView.heightAnchor.constraint(lessThanOrEqualTo: view.heightAnchor, constant: -headerHeight) + midHeight.priority = UILayoutPriority.defaultLow + scrollView.constrain([ + minHeight, + midHeight, + maxHeight, + ]) + } + + private func setInitialData() { + view.backgroundColor = .clear + scrollView.backgroundColor = UIColor.litecoinGray + + scrollView.delaysContentTouches = false + if var modalPresentable = childViewController as? ModalPresentable { + modalPresentable.parentView = view + } + + tapGestureRecognizer.delegate = self + tapGestureRecognizer.addTarget(self, action: #selector(didTap)) + view.addGestureRecognizer(tapGestureRecognizer) + store.subscribe(self, name: .blockModalDismissal, callback: { _ in + self.tapGestureRecognizer.isEnabled = false + }) + + store.subscribe(self, name: .unblockModalDismissal, callback: { _ in + self.tapGestureRecognizer.isEnabled = true + }) + addTopCorners() + header.closeCallback = { [weak self] in + if let delegate = self?.transitioningDelegate as? ModalTransitionDelegate { + delegate.reset() + } + self?.dismiss(animated: true, completion: {}) + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOpacity = 0.15 + view.layer.shadowRadius = 4.0 + view.layer.shadowOffset = .zero + } + + // Even though the status bar is hidden for this view, + // it still needs to be set to light as it will temporarily + // transition to black when this view gets presented + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + override var prefersStatusBarHidden: Bool { + return true + } + + @objc private func didTap() { + guard let modalTransitionDelegate = transitioningDelegate as? ModalTransitionDelegate else { return } + modalTransitionDelegate.reset() + dismiss(animated: true, completion: nil) + } + + private func addTopCorners() { + let path = UIBezierPath(roundedRect: view.bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: 6.0, height: 6.0)).cgPath + let maskLayer = CAShapeLayer() + maskLayer.path = path + header.layer.mask = maskLayer + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension ModalViewController: UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + let location = gestureRecognizer.location(in: view) + if location.y < header.frame.minY { + return true + } else { + return false + } + } +} diff --git a/litewallet/src/ViewControllers/RootModals/ReceiveViewController.swift b/litewallet/src/ViewControllers/RootModals/ReceiveViewController.swift new file mode 100644 index 000000000..0a389cc7d --- /dev/null +++ b/litewallet/src/ViewControllers/RootModals/ReceiveViewController.swift @@ -0,0 +1,282 @@ +import UIKit + +private let qrSize: CGFloat = 186.0 +private let smallButtonHeight: CGFloat = 32.0 +private let buttonPadding: CGFloat = 20.0 +private let smallSharePadding: CGFloat = 12.0 +private let largeSharePadding: CGFloat = 20.0 + +typealias PresentShare = (String, UIImage) -> Void + +class ReceiveViewController: UIViewController, Subscriber, Trackable { + // MARK: - Public + + var presentEmail: PresentShare? + var presentText: PresentShare? + + init(wallet: BRWallet, store: Store, isRequestAmountVisible: Bool) { + self.wallet = wallet + self.isRequestAmountVisible = isRequestAmountVisible + self.store = store + LWAnalytics.logEventWithParameters(itemName: ._20202116_VRC) + super.init(nibName: nil, bundle: nil) + } + + // MARK: - Private + + private let qrCode = UIImageView() + private let address = UILabel(font: .customBody(size: 14.0)) + private let addressPopout = InViewAlert(type: .primary) + private let share = ShadowButton(title: S.Receive.share.localize(), type: .tertiary, image: #imageLiteral(resourceName: "Share")) + private let sharePopout = InViewAlert(type: .secondary) + private let border = UIView() + private let request = ShadowButton(title: S.Receive.request.localize(), type: .flatLitecoinBlue) + private let addressButton = UIButton(type: .system) + private var topSharePopoutConstraint: NSLayoutConstraint? + private let wallet: BRWallet + private let store: Store + private var balance: UInt64? { + didSet { + if let newValue = balance, let oldValue = oldValue { + if newValue > oldValue { + setReceiveAddress() + } + } + } + } + + fileprivate let isRequestAmountVisible: Bool + private var requestTop: NSLayoutConstraint? + private var requestBottom: NSLayoutConstraint? + + override func viewDidLoad() { + addSubviews() + addConstraints() + setStyle() + addActions() + setupCopiedMessage() + setupShareButtons() + store.subscribe(self, selector: { $0.walletState.balance != $1.walletState.balance }, callback: { + self.balance = $0.walletState.balance + }) + } + + private func addSubviews() { + view.addSubview(qrCode) + view.addSubview(address) + view.addSubview(addressPopout) + view.addSubview(share) + view.addSubview(sharePopout) + view.addSubview(border) + view.addSubview(request) + view.addSubview(addressButton) + } + + private func addConstraints() { + qrCode.constrain([ + qrCode.constraint(.width, constant: qrSize), + qrCode.constraint(.height, constant: qrSize), + qrCode.constraint(.top, toView: view, constant: C.padding[4]), + qrCode.constraint(.centerX, toView: view), + ]) + address.constrain([ + address.constraint(toBottom: qrCode, constant: C.padding[1]), + address.constraint(.centerX, toView: view), + ]) + addressPopout.heightConstraint = addressPopout.constraint(.height, constant: 0.0) + addressPopout.constrain([ + addressPopout.constraint(toBottom: address, constant: 0.0), + addressPopout.constraint(.centerX, toView: view), + addressPopout.constraint(.width, toView: view), + addressPopout.heightConstraint, + ]) + share.constrain([ + share.constraint(toBottom: addressPopout, constant: C.padding[2]), + share.constraint(.centerX, toView: view), + share.constraint(.width, constant: qrSize), + share.constraint(.height, constant: smallButtonHeight), + ]) + sharePopout.heightConstraint = sharePopout.constraint(.height, constant: 0.0) + topSharePopoutConstraint = sharePopout.constraint(toBottom: share, constant: largeSharePadding) + sharePopout.constrain([ + topSharePopoutConstraint, + sharePopout.constraint(.centerX, toView: view), + sharePopout.constraint(.width, toView: view), + sharePopout.heightConstraint, + ]) + border.constrain([ + border.constraint(.width, toView: view), + border.constraint(toBottom: sharePopout, constant: 0.0), + border.constraint(.centerX, toView: view), + border.constraint(.height, constant: 1.0), + ]) + requestTop = request.constraint(toBottom: border, constant: C.padding[3]) + requestBottom = request.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: E.isIPhoneX ? -C.padding[5] : -C.padding[2]) + request.constrain([ + requestTop, + request.constraint(.leading, toView: view, constant: C.padding[2]), + request.constraint(.trailing, toView: view, constant: -C.padding[2]), + request.constraint(.height, constant: C.Sizes.buttonHeight), + requestBottom, + ]) + addressButton.constrain([ + addressButton.leadingAnchor.constraint(equalTo: address.leadingAnchor, constant: -C.padding[1]), + addressButton.topAnchor.constraint(equalTo: qrCode.topAnchor), + addressButton.trailingAnchor.constraint(equalTo: address.trailingAnchor, constant: C.padding[1]), + addressButton.bottomAnchor.constraint(equalTo: address.bottomAnchor, constant: C.padding[1]), + ]) + } + + private func setStyle() { + view.backgroundColor = UIColor.litecoinGray + address.textColor = .liteWalletBlue + border.backgroundColor = .secondaryBorder + share.isToggleable = true + if !isRequestAmountVisible { + border.isHidden = true + request.isHidden = true + request.constrain([ + request.heightAnchor.constraint(equalToConstant: 0.0), + ]) + requestTop?.constant = 0.0 + requestBottom?.constant = 0.0 + } + sharePopout.clipsToBounds = true + addressButton.setBackgroundImage(UIImage.imageForColor(.secondaryShadow), for: .highlighted) + addressButton.layer.cornerRadius = 4.0 + addressButton.layer.masksToBounds = true + setReceiveAddress() + } + + private func setReceiveAddress() { + address.text = wallet.receiveAddress + qrCode.image = UIImage.qrCode(data: "\(address.text!)".data(using: .utf8)!, color: CIColor(color: .black))? + .resize(CGSize(width: qrSize, height: qrSize))! + } + + private func addActions() { + addressButton.tap = { [weak self] in + self?.addressTapped() + } + request.tap = { [weak self] in + guard let modalTransitionDelegate = self?.parent?.transitioningDelegate as? ModalTransitionDelegate else { return } + modalTransitionDelegate.reset() + self?.dismiss(animated: true, completion: { + self?.store.perform(action: RootModalActions.Present(modal: .requestAmount)) + }) + } + share.addTarget(self, action: #selector(ReceiveViewController.shareTapped), for: .touchUpInside) + } + + private func setupCopiedMessage() { + let copiedMessage = UILabel(font: .customMedium(size: 14.0)) + copiedMessage.textColor = .white + copiedMessage.text = S.Receive.copied.localize() + copiedMessage.textAlignment = .center + addressPopout.contentView = copiedMessage + } + + private func setupShareButtons() { + let container = UIView() + container.translatesAutoresizingMaskIntoConstraints = false + let email = ShadowButton(title: S.Receive.emailButton.localize(), type: .tertiary) + let text = ShadowButton(title: S.Receive.textButton.localize(), type: .tertiary) + container.addSubview(email) + container.addSubview(text) + email.constrain([ + email.constraint(.leading, toView: container, constant: C.padding[2]), + email.constraint(.top, toView: container, constant: buttonPadding), + email.constraint(.bottom, toView: container, constant: -buttonPadding), + email.trailingAnchor.constraint(equalTo: container.centerXAnchor, constant: -C.padding[1]), + ]) + text.constrain([ + text.constraint(.trailing, toView: container, constant: -C.padding[2]), + text.constraint(.top, toView: container, constant: buttonPadding), + text.constraint(.bottom, toView: container, constant: -buttonPadding), + text.leadingAnchor.constraint(equalTo: container.centerXAnchor, constant: C.padding[1]), + ]) + sharePopout.contentView = container + email.addTarget(self, action: #selector(ReceiveViewController.emailTapped), for: .touchUpInside) + text.addTarget(self, action: #selector(ReceiveViewController.textTapped), for: .touchUpInside) + } + + @objc private func shareTapped() { + toggle(alertView: sharePopout, shouldAdjustPadding: true) + if addressPopout.isExpanded { + toggle(alertView: addressPopout, shouldAdjustPadding: false) + } + } + + @objc private func addressTapped() { + guard let text = address.text else { return } + saveEvent("receive.copiedAddress") + UIPasteboard.general.string = text + toggle(alertView: addressPopout, shouldAdjustPadding: false, shouldShrinkAfter: true) + if sharePopout.isExpanded { + toggle(alertView: sharePopout, shouldAdjustPadding: true) + } + } + + @objc private func emailTapped() { + presentEmail?(address.text!, qrCode.image!) + } + + @objc private func textTapped() { + presentText?(address.text!, qrCode.image!) + } + + private func toggle(alertView: InViewAlert, shouldAdjustPadding: Bool, shouldShrinkAfter: Bool = false) + { + share.isEnabled = false + address.isUserInteractionEnabled = false + + var deltaY = alertView.isExpanded ? -alertView.height : alertView.height + if shouldAdjustPadding { + if deltaY > 0 { + deltaY -= (largeSharePadding - smallSharePadding) + } else { + deltaY += (largeSharePadding - smallSharePadding) + } + } + + if alertView.isExpanded { + alertView.contentView?.isHidden = true + } + + UIView.spring(C.animationDuration, animations: { + if shouldAdjustPadding { + let newPadding = self.sharePopout.isExpanded ? largeSharePadding : smallSharePadding + self.topSharePopoutConstraint?.constant = newPadding + } + alertView.toggle() + self.parent?.view.layoutIfNeeded() + }, completion: { _ in + alertView.isExpanded = !alertView.isExpanded + self.share.isEnabled = true + self.address.isUserInteractionEnabled = true + alertView.contentView?.isHidden = false + if shouldShrinkAfter { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + if alertView.isExpanded { + self.toggle(alertView: alertView, shouldAdjustPadding: shouldAdjustPadding) + } + } + } + }) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension ReceiveViewController: ModalDisplayable { + var faqArticleId: String? { + return ArticleIds.nothing + } + + var modalTitle: String { + return S.Receive.title.localize() + } +} diff --git a/litewallet/src/ViewControllers/RootModals/SendViewController.swift b/litewallet/src/ViewControllers/RootModals/SendViewController.swift new file mode 100644 index 000000000..218370beb --- /dev/null +++ b/litewallet/src/ViewControllers/RootModals/SendViewController.swift @@ -0,0 +1,543 @@ +import BRCore +import FirebaseAnalytics +import KeychainAccess +import LocalAuthentication +import SwiftUI +import UIKit + +typealias PresentScan = (@escaping ScanCompletion) -> Void + +class SendViewController: UIViewController, Subscriber, ModalPresentable, Trackable { + // MARK: - Public + + var presentScan: PresentScan? + var presentVerifyPin: ((String, @escaping VerifyPinCallback) -> Void)? + var onPublishSuccess: (() -> Void)? + var onResolvedSuccess: (() -> Void)? + var onResolutionFailure: ((String) -> Void)? + var parentView: UIView? // ModalPresentable + var initialAddress: String? + var isPresentedFromLock = false + var hasActivatedInlineFees: Bool = true + + // MARK: - Private + + private let store: Store + private let sender: Sender + private let walletManager: WalletManager + private let amountView: AmountViewController + private let sendAddressCell = AddressCell() + private let memoCell = DescriptionSendCell(placeholder: S.Send.descriptionLabel.localize()) + private var sendButtonCell = SendButtonHostingController() + private let currency: ShadowButton + private var balance: UInt64 = 0 + private var amount: Satoshis? + private var combinedFee: Satoshis? + private var didIgnoreUsedAddressWarning = false + private var didIgnoreIdentityNotCertified = false + private let initialRequest: PaymentRequest? + private let confirmTransitioningDelegate = TransitioningDelegate() + private var feeType: FeeType? + private let keychainPreferences = Keychain(service: "litewallet.user-prefs") + private var adjustmentHeight: CGFloat = 0.0 + private var buttonToBorder: CGFloat = 0.0 + + init(store: Store, sender: Sender, walletManager: WalletManager, initialAddress: String? = nil, initialRequest: PaymentRequest? = nil) + { + self.store = store + self.sender = sender + self.walletManager = walletManager + self.initialAddress = initialAddress + self.initialRequest = initialRequest + + currency = ShadowButton(title: S.Symbols.currencyButtonTitle(maxDigits: store.state.maxDigits), type: .tertiary) + + /// User Preference + if let opsPreference = keychainPreferences["hasAcceptedFees"], + opsPreference == "false" + { + hasActivatedInlineFees = false + } else { + keychainPreferences["has-accepted-fees"] = "true" + } + + amountView = AmountViewController(store: store, isPinPadExpandedAtLaunch: false, hasAcceptedFees: hasActivatedInlineFees) + + LWAnalytics.logEventWithParameters(itemName: ._20191105_VSC) + + super.init(nibName: nil, bundle: nil) + } + + // MARK: - Private + + deinit { + store.unsubscribe(self) + NotificationCenter.default.removeObserver(self) + } + + override func viewDidLoad() { + view.backgroundColor = UIColor.litecoinGray + + // set as regular at didLoad + walletManager.wallet?.feePerKb = store.state.fees.regular + + // polish parameters + memoCell.backgroundColor = UIColor.litecoinGray + amountView.view.backgroundColor = UIColor.litecoinGray + + view.addSubview(sendAddressCell) + view.addSubview(memoCell) + view.addSubview(sendButtonCell.view) + + sendAddressCell.invalidateIntrinsicContentSize() + sendAddressCell.constrainTopCorners(height: SendCell.defaultHeight) + + memoCell.constrain([ + memoCell.widthAnchor.constraint(equalTo: sendAddressCell.widthAnchor), + memoCell.topAnchor.constraint(equalTo: sendAddressCell.bottomAnchor), + memoCell.leadingAnchor.constraint(equalTo: sendAddressCell.leadingAnchor), + memoCell.heightAnchor.constraint(equalTo: memoCell.textView.heightAnchor, constant: C.padding[3]), + ]) + memoCell.accessoryView.constrain([ + memoCell.accessoryView.constraint(.width, constant: 0.0), + ]) + addChildViewController(amountView, layout: { + amountView.view.constrain([ + amountView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + amountView.view.topAnchor.constraint(equalTo: memoCell.bottomAnchor), + amountView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + }) + + sendButtonCell.view.constrain([ + sendButtonCell.view.constraint(.leading, toView: view), + sendButtonCell.view.constraint(.trailing, toView: view), + sendButtonCell.view.constraint(toBottom: amountView.view, constant: buttonToBorder), + sendButtonCell.view.constraint(.height, constant: C.Sizes.sendButtonHeight), + sendButtonCell.view + .bottomAnchor + .constraint(equalTo: view.bottomAnchor, constant: -C.padding[8]), + ]) + + addButtonActions() + store.subscribe(self, selector: { $0.walletState.balance != $1.walletState.balance }, + callback: { + if let balance = $0.walletState.balance { + self.balance = balance + } + }) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if initialAddress != nil { + amountView.expandPinPad() + } else if let initialRequest = initialRequest { + handleRequest(initialRequest) + } + } + + private func addButtonActions() { + // MARK: - MemoCell Callbacks + + memoCell.didReturn = { textView in + textView.resignFirstResponder() + } + memoCell.didBeginEditing = { [weak self] in + self?.amountView.closePinPad() + } + + // MARK: - AmountView Callbacks + + amountView.balanceTextForAmount = { [weak self] amount, rate in + self?.balanceTextForAmount(amount: amount, rate: rate) + } + + amountView.didUpdateAmount = { [weak self] amount in + self?.amount = amount + } + amountView.didUpdateFee = strongify(self) { myself, feeType in + myself.feeType = feeType + let fees = myself.store.state.fees + + switch feeType { + case .regular: myself.walletManager.wallet?.feePerKb = fees.regular + case .economy: myself.walletManager.wallet?.feePerKb = fees.economy + case .luxury: myself.walletManager.wallet?.feePerKb = fees.luxury + } + + myself.amountView.updateBalanceLabel() + } + + amountView.didChangeFirstResponder = { [weak self] isFirstResponder in + if isFirstResponder { + self?.memoCell.textView.resignFirstResponder() + self?.sendAddressCell.textField.resignFirstResponder() + /// copyKeyboardChangeAnimation(willShow: true, notification: notification) + } + } + + // MARK: - SendAddressView Model Callbacks / Actions + + sendAddressCell.paste.addTarget(self, action: #selector(SendViewController.pasteTapped), for: .touchUpInside) + sendAddressCell.scan.addTarget(self, action: #selector(SendViewController.scanTapped), for: .touchUpInside) + + sendAddressCell.didBeginEditing = strongify(self) { _ in + // myself.amountView.closePinPad() + } + + sendAddressCell.didEndEditing = strongify(self) { myself in + myself.resignFirstResponder() + } + + sendAddressCell.didReceivePaymentRequest = { [weak self] request in + self?.handleRequest(request) + } + + // MARK: - SendButton Model Callbacks / Actions + + sendButtonCell.rootView.doSendTransaction = { + guard let sendAddress = self.sendAddressCell.address else { return } + if sendAddress.isValidAddress { + self.sendTapped() + } else { + self.showAlert(title: S.LitewalletAlert.error.localize(), message: S.Transaction.invalid.localize(), + buttonLabel: S.Button.ok.localize()) + } + } + } + + private func balanceTextForAmount(amount: Satoshis?, rate: Rate?) -> (NSAttributedString?, NSAttributedString?) + { + let balanceAmount = DisplayAmount(amount: Satoshis(rawValue: balance), state: store.state, selectedRate: rate, minimumFractionDigits: 2) + let balanceText = balanceAmount.description + + let balanceOutput = String(format: S.Send.balance.localize(), balanceText) + var feeOutput = "" + var balanceColor: UIColor = .grayTextTint + + /// Check the amount is greater than zero and if user is opting out of fees + if let amount = amount, amount > 0 { + let tieredOpsFee = tieredOpsFee(amount: amount.rawValue) + let totalAmountToCalculateFees = hasActivatedInlineFees ? (amount.rawValue + tieredOpsFee) : amount.rawValue + + let fee = sender.feeForTx(amount: totalAmountToCalculateFees) + + let feeAmountLabel = DisplayAmount(amount: Satoshis(rawValue: fee) + tieredOpsFee, + state: store.state, + selectedRate: rate, + minimumFractionDigits: 2) + + let feeText = feeAmountLabel.description.replacingZeroFeeWithTenCents() + feeOutput = hasActivatedInlineFees ? String(format: S.Send.fee.localize(), feeText) : + String(format: S.Send.feeBlank.localize(), feeText) + + if balance >= (fee + tieredOpsFee), amount.rawValue > (balance - (fee + tieredOpsFee)) { + balanceColor = .litewalletOrange + } + } + + let balanceStyle = [ + NSAttributedString.Key.font: UIFont.customBody(size: 14.0), + NSAttributedString.Key.foregroundColor: balanceColor, + ] + + let balanceAttributes: [NSAttributedString.Key: Any] = balanceStyle + let feeAttributes: [NSAttributedString.Key: Any] = balanceStyle + + return (NSAttributedString(string: balanceOutput, attributes: balanceAttributes), NSAttributedString(string: feeOutput, attributes: feeAttributes)) + } + + @objc private func pasteTapped() { + guard let pasteboard = UIPasteboard.general.string, !pasteboard.utf8.isEmpty + else { + return showAlert(title: S.Send.invalidAddressTitle.localize(), message: S.Send.noAddress.localize(), buttonLabel: S.Button.ok.localize()) + } + guard let request = PaymentRequest(string: pasteboard) + else { + return showAlert(title: S.Send.invalidAddressTitle.localize(), message: S.Send.noAddress.localize(), buttonLabel: S.Button.ok.localize()) + } + + handleRequest(request) + sendAddressCell.textField.text = pasteboard + sendAddressCell.textField.layoutIfNeeded() + } + + @objc private func scanTapped() { + memoCell.textView.resignFirstResponder() + + presentScan? { [weak self] paymentRequest in + guard let request = paymentRequest else { return } + guard let destinationAddress = paymentRequest?.toAddress else { return } + + self?.handleRequest(request) + self?.sendAddressCell.textField.text = destinationAddress + } + } + + @objc private func sendTapped() { + if sendAddressCell.textField.isFirstResponder { + sendAddressCell.textField.resignFirstResponder() + } + let bareAmount: Satoshis? + if sender.transaction == nil { + guard let address = sendAddressCell.address else { + return showAlert(title: S.LitewalletAlert.error.localize(), + message: S.Send.noAddress.localize(), buttonLabel: S.Button.ok.localize()) + } + + if !address.isValidAddress { + return showAlert(title: S.LitewalletAlert.error.localize(), + message: S.Send.noAddress.localize(), + buttonLabel: S.Button.ok.localize()) + } + + guard var amountToSend = amount + else { + return showAlert(title: S.LitewalletAlert.error.localize(), + message: S.Send.noAmount.localize(), + buttonLabel: S.Button.ok.localize()) + } + + let opsFeeAmount = Satoshis(rawValue: tieredOpsFee(amount: amountToSend.rawValue)) + let fee = walletManager.wallet?.feeForTx(amount: amountToSend.rawValue + opsFeeAmount.rawValue) + let feeInSatoshis = Satoshis(rawValue: fee ?? 0) + bareAmount = amountToSend + + /// Set ops fees + if hasActivatedInlineFees { + amountToSend = amountToSend + opsFeeAmount + } + + if let minOutput = walletManager.wallet?.minOutputAmount { + guard amountToSend.rawValue >= minOutput + else { + let minOutputAmount = Amount(amount: minOutput, rate: Rate.empty, maxDigits: store.state.maxDigits) + let message = String(format: S.PaymentProtocol.Errors.smallPayment.localize(), + minOutputAmount.string(isLtcSwapped: store.state.isLtcSwapped)) + return showAlert(title: S.LitewalletAlert.error.localize(), + message: message, + buttonLabel: S.Button.ok.localize()) + } + } + guard !(walletManager.wallet?.containsAddress(address) ?? false) + else { + return showAlert(title: S.LitewalletAlert.error.localize(), + message: S.Send.containsAddress.localize(), + buttonLabel: S.Button.ok.localize()) + } + guard amountToSend.rawValue <= (walletManager.wallet?.maxOutputAmount ?? 0) + else { + return showAlert(title: S.LitewalletAlert.error.localize(), + message: S.Send.insufficientFunds.localize(), + buttonLabel: S.Button.ok.localize()) + } + + /// Set Ops or Single Output + if hasActivatedInlineFees { + guard let bareAmt = bareAmount?.rawValue, + sender.createTransactionWithOpsOutputs(amount: bareAmt, to: address) + else { + return showAlert(title: S.LitewalletAlert.error.localize(), + message: S.Send.createTransactionError.localize(), + buttonLabel: S.Button.ok.localize()) + } + } else { + guard let bareAmt = bareAmount?.rawValue, + sender.createTransaction(amount: bareAmt, to: address) + else { + return showAlert(title: S.LitewalletAlert.error.localize(), + message: S.Send.createTransactionError.localize(), + buttonLabel: S.Button.ok.localize()) + } + } + + let confirm = ConfirmationViewController(amount: bareAmount ?? Satoshis(0), + txFee: feeInSatoshis, + opsFee: opsFeeAmount, + feeType: feeType ?? .regular, state: store.state, + selectedRate: amountView.selectedRate, + minimumFractionDigits: amountView.minimumFractionDigits, + address: address, isUsingBiometrics: sender.canUseBiometrics) + + confirm.successCallback = { + confirm.dismiss(animated: true, completion: { + self.send() + }) + } + confirm.cancelCallback = { + confirm.dismiss(animated: true, completion: { + self.sender.transaction = nil + }) + } + confirmTransitioningDelegate.shouldShowMaskView = false + confirm.transitioningDelegate = confirmTransitioningDelegate + confirm.modalPresentationStyle = UIModalPresentationStyle.overFullScreen + confirm.modalPresentationCapturesStatusBarAppearance = true + present(confirm, animated: true, completion: nil) + + } else { + NSLog("Error: transaction is nil") + } + } + + private func handleRequest(_ request: PaymentRequest) { + switch request.type { + case .local: + + if let amount = request.amount { + amountView.forceUpdateAmount(amount: amount) + } + if request.label != nil { + memoCell.content = request.label + } + + case .remote: + let loadingView = BRActivityViewController(message: S.Send.loadingRequest.localize()) + present(loadingView, animated: true, completion: nil) + request.fetchRemoteRequest(completion: { [weak self] request in + DispatchQueue.main.async { + loadingView.dismiss(animated: true, completion: { + if let paymentProtocolRequest = request?.paymentProtocolRequest { + self?.confirmProtocolRequest(protoReq: paymentProtocolRequest) + } else { + self?.showErrorMessage(S.Send.remoteRequestError.localize()) + } + }) + } + }) + } + } + + private func send() { + guard let rate = store.state.currentRate else { return } + guard let feePerKb = walletManager.wallet?.feePerKb else { return } + + sender.send(biometricsMessage: S.VerifyPin.touchIdMessage.localize(), + rate: rate, + comment: memoCell.textView.text, + feePerKb: feePerKb, + verifyPinFunction: { [weak self] pinValidationCallback in + self?.presentVerifyPin?(S.VerifyPin.authorize.localize()) { [weak self] pin, vc in + if pinValidationCallback(pin) { + vc.dismiss(animated: true, completion: { + self?.parent?.view.isFrameChangeBlocked = false + }) + return true + } else { + return false + } + } + }, completion: { [weak self] result in + switch result { + case .success: + self?.dismiss(animated: true, completion: { + guard let myself = self else { return } + myself.store.trigger(name: .showStatusBar) + if myself.isPresentedFromLock { + myself.store.trigger(name: .loginFromSend) + } + myself.onPublishSuccess?() + }) + self?.saveEvent("send.success") + self?.sendAddressCell.textField.text = "" + self?.memoCell.textView.text = "" + LWAnalytics.logEventWithParameters(itemName: ._20191105_DSL) + + case let .creationError(message): + self?.showAlert(title: S.Send.createTransactionError.localize(), message: message, buttonLabel: S.Button.ok.localize()) + self?.saveEvent("send.publishFailed", attributes: ["errorMessage": message]) + case let .publishFailure(error): + if case let .posixError(code, description) = error { + self?.showAlert(title: S.SecurityAlerts.sendFailure.localize(), message: "\(description) (\(code))", buttonLabel: S.Button.ok.localize()) + self?.saveEvent("send.publishFailed", attributes: ["errorMessage": "\(description) (\(code))"]) + } + } + }) + } + + func confirmProtocolRequest(protoReq: PaymentProtocolRequest) { + guard let firstOutput = protoReq.details.outputs.first else { return } + guard let wallet = walletManager.wallet else { return } + + let address = firstOutput.updatedSwiftAddress + let isValid = protoReq.isValid() + var isOutputTooSmall = false + + if let errorMessage = protoReq.errorMessage, errorMessage == S.PaymentProtocol.Errors.requestExpired.localize(), !isValid + { + return showAlert(title: S.PaymentProtocol.Errors.badPaymentRequest.localize(), message: errorMessage, buttonLabel: S.Button.ok.localize()) + } + + // TODO: check for duplicates of already paid requests + var requestAmount = Satoshis(0) + protoReq.details.outputs.forEach { output in + if output.amount > 0, output.amount < wallet.minOutputAmount { + isOutputTooSmall = true + } + requestAmount += output.amount + } + + if wallet.containsAddress(address) { + return showAlert(title: S.LitewalletAlert.warning.localize(), message: S.Send.containsAddress.localize(), buttonLabel: S.Button.ok.localize()) + } else if wallet.addressIsUsed(address), !didIgnoreUsedAddressWarning { + let message = "\(S.Send.UsedAddress.title.localize())\n\n\(S.Send.UsedAddress.firstLine.localize())\n\n\(S.Send.UsedAddress.secondLine.localize())" + return showError(title: S.LitewalletAlert.warning.localize(), message: message, ignore: { [weak self] in + self?.didIgnoreUsedAddressWarning = true + self?.confirmProtocolRequest(protoReq: protoReq) + }) + } else if let message = protoReq.errorMessage, !message.utf8.isEmpty, (protoReq.commonName?.utf8.count)! > 0, !didIgnoreIdentityNotCertified + { + return showError(title: S.Send.identityNotCertified.localize(), message: message, ignore: { [weak self] in + self?.didIgnoreIdentityNotCertified = true + self?.confirmProtocolRequest(protoReq: protoReq) + }) + } else if requestAmount < wallet.minOutputAmount { + let amount = Amount(amount: wallet.minOutputAmount, rate: Rate.empty, maxDigits: store.state.maxDigits) + let message = String(format: S.PaymentProtocol.Errors.smallPayment.localize(), amount.bits) + return showAlert(title: S.PaymentProtocol.Errors.smallOutputErrorTitle.localize(), message: message, buttonLabel: S.Button.ok.localize()) + } else if isOutputTooSmall { + let amount = Amount(amount: wallet.minOutputAmount, rate: Rate.empty, maxDigits: store.state.maxDigits) + let message = String(format: S.PaymentProtocol.Errors.smallTransaction.localize(), amount.bits) + return showAlert(title: S.PaymentProtocol.Errors.smallOutputErrorTitle.localize(), message: message, buttonLabel: S.Button.ok.localize()) + } + + if requestAmount > 0 { + amountView.forceUpdateAmount(amount: requestAmount) + } + memoCell.content = protoReq.details.memo + + if requestAmount == 0 { + if let amount = amount { + guard sender.createTransaction(amount: amount.rawValue, to: address) + else { + return showAlert(title: S.LitewalletAlert.error.localize(), message: S.Send.createTransactionError.localize(), buttonLabel: S.Button.ok.localize()) + } + } + } + } + + private func showError(title: String, message: String, ignore: @escaping () -> Void) { + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: S.Button.ignore.localize(), style: .default, handler: { _ in + ignore() + })) + alertController.addAction(UIAlertAction(title: S.Button.cancel.localize(), style: .cancel, handler: nil)) + present(alertController, animated: true, completion: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension SendViewController: ModalDisplayable { + var faqArticleId: String? { + return ArticleIds.nothing + } + + var modalTitle: String { + return S.Send.title.localize() + } +} diff --git a/litewallet/src/ViewControllers/ScanViewController.swift b/litewallet/src/ViewControllers/ScanViewController.swift new file mode 100644 index 000000000..4690c5b96 --- /dev/null +++ b/litewallet/src/ViewControllers/ScanViewController.swift @@ -0,0 +1,233 @@ +import AVFoundation +import UIKit + +typealias ScanCompletion = (PaymentRequest?) -> Void +typealias KeyScanCompletion = (String) -> Void + +class ScanViewController: UIViewController, Trackable { + // TODO: Add a storyboard + @IBOutlet var cameraOverlayView: UIView! + @IBOutlet var toolbarView: UIView! + @IBOutlet var overlayViewTitleLabel: UILabel! + + @IBOutlet var closeButton: UIButton! + @IBOutlet var flashButton: UIButton! + + static func presentCameraUnavailableAlert(fromRoot: UIViewController) { + let alertController = UIAlertController(title: S.Send.cameraUnavailableTitle.localize(), message: S.Send.cameraUnavailableMessage.localize(), preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: S.Button.cancel.localize(), style: .cancel, handler: nil)) + alertController.addAction(UIAlertAction(title: S.Button.settings.localize(), style: .default, handler: { _ in + if let appSettings = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(appSettings, options: [:], completionHandler: nil) + } + })) + fromRoot.present(alertController, animated: true, completion: nil) + } + + static var isCameraAllowed: Bool { + return AVCaptureDevice.authorizationStatus(for: AVMediaType.video) != .denied + } + + let completion: ScanCompletion? + let scanKeyCompletion: KeyScanCompletion? + let isValidURI: (String) -> Bool + + fileprivate let guide = CameraGuideView() + fileprivate let session = AVCaptureSession() + private let toolbar = UIView() + private let close = UIButton.close + private let flash = UIButton.icon(image: UIImage(named: "flashIcon")!, accessibilityLabel: S.Scanner.flashButtonLabel.localize()) + fileprivate var currentUri = "" + + init(completion: @escaping ScanCompletion, isValidURI: @escaping (String) -> Bool) { + self.completion = completion + scanKeyCompletion = nil + self.isValidURI = isValidURI + super.init(nibName: nil, bundle: nil) + } + + init(scanKeyCompletion: @escaping KeyScanCompletion, isValidURI: @escaping (String) -> Bool) { + self.scanKeyCompletion = scanKeyCompletion + completion = nil + self.isValidURI = isValidURI + super.init(nibName: nil, bundle: nil) + } + + override func viewDidLoad() { + view.backgroundColor = .black + toolbar.backgroundColor = .secondaryButton + + view.addSubview(toolbar) + toolbar.addSubview(close) + toolbar.addSubview(flash) + view.addSubview(guide) + + toolbar.constrainBottomCorners(sidePadding: 0, bottomPadding: 0) + if E.isIPhoneX { + toolbar.constrain([toolbar.constraint(.height, constant: 60.0)]) + + close.constrain([ + close.constraint(.leading, toView: toolbar), + close.constraint(.top, toView: toolbar, constant: 2.0), + close.constraint(.width, constant: 50.0), + close.constraint(.height, constant: 50.0), + ]) + + flash.constrain([ + flash.constraint(.trailing, toView: toolbar), + flash.constraint(.top, toView: toolbar, constant: 2.0), + flash.constraint(.width, constant: 50.0), + flash.constraint(.height, constant: 50.0), + ]) + } else { + toolbar.constrain([toolbar.constraint(.height, constant: 60.0)]) + + close.constrain([ + close.constraint(.leading, toView: toolbar, constant: 10.0), + close.constraint(.top, toView: toolbar, constant: 2.0), + close.constraint(.bottom, toView: toolbar, constant: -2.0), + close.constraint(.width, constant: 50.0), + ]) + + flash.constrain([ + flash.constraint(.trailing, toView: toolbar, constant: -10.0), + flash.constraint(.top, toView: toolbar, constant: 2.0), + flash.constraint(.bottom, toView: toolbar, constant: -2.0), + flash.constraint(.width, constant: 50.0), + ]) + } + + guide.constrain([ + guide.constraint(.leading, toView: view, constant: C.padding[6]), + guide.constraint(.trailing, toView: view, constant: -C.padding[6]), + guide.constraint(.centerY, toView: view), + NSLayoutConstraint(item: guide, attribute: .width, relatedBy: .equal, toItem: guide, attribute: .height, multiplier: 1.0, constant: 0.0), + ]) + guide.transform = CGAffineTransform(scaleX: 0.0, y: 0.0) + + close.tap = { [weak self] in + self?.saveEvent("scan.dismiss") + self?.dismiss(animated: true, completion: { + self?.completion?(nil) + }) + } + + addCameraPreview() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + UIView.spring(0.8, animations: { + self.guide.transform = .identity + }, completion: { _ in }) + } + + private func addCameraPreview() { + guard let device = AVCaptureDevice.default(for: AVMediaType.video) else { return } + guard let input = try? AVCaptureDeviceInput(device: device) else { return } + session.addInput(input) + let previewLayer = AVCaptureVideoPreviewLayer(session: session) + previewLayer.frame = view.bounds + view.layer.insertSublayer(previewLayer, at: 0) + + let output = AVCaptureMetadataOutput() + output.setMetadataObjectsDelegate(self, queue: .main) + session.addOutput(output) + + if output.availableMetadataObjectTypes.contains(where: { objectType in + objectType == AVMetadataObject.ObjectType.qr + }) { + output.metadataObjectTypes = [AVMetadataObject.ObjectType.qr] + } else { + print("no qr code support") + } + + DispatchQueue(label: "qrscanner").async { + self.session.startRunning() + } + + if device.hasTorch { + flash.tap = { [weak self] in + do { + try device.lockForConfiguration() + device.torchMode = device.torchMode == .on ? .off : .on + device.unlockForConfiguration() + if device.torchMode == .on { + self?.saveEvent("scan.torchOn") + } else { + self?.saveEvent("scan.torchOn") + } + } catch { + print("Camera Torch error: \(error)") + } + } + } + } + + override var prefersStatusBarHidden: Bool { + return true + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension ScanViewController: AVCaptureMetadataOutputObjectsDelegate { + func metadataOutput(_: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from _: AVCaptureConnection) + { + if let data = metadataObjects as? [AVMetadataMachineReadableCodeObject] { + if data.isEmpty { + guide.state = .normal + } else { + data.forEach { + guard let uri = $0.stringValue + else { + NSLog("ERROR: URI String not found") + return + } + if completion != nil, guide.state != .positive { + handleURI(uri) + } else if scanKeyCompletion != nil, guide.state != .positive { + handleKey(uri) + } + } + } + } else { + NSLog("ERROR: data metadata objects not accessed") + } + } + + func handleURI(_ uri: String) { + if currentUri != uri { + currentUri = uri + if let paymentRequest = PaymentRequest(string: uri) { + saveEvent("scan.litecoinUri") + guide.state = .positive + // Add a small delay so the green guide will be seen + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + self.dismiss(animated: true, completion: { + self.completion?(paymentRequest) + }) + } + } else { + guide.state = .negative + } + } + } + + func handleKey(_ keyString: String) { + if isValidURI(keyString) { + saveEvent("scan.privateKey") + guide.state = .positive + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + self.dismiss(animated: true, completion: { + self.scanKeyCompletion?(keyString) + }) + } + } else { + guide.state = .negative + } + } +} diff --git a/litewallet/src/ViewControllers/SecurityCenter/SecurityCenterViewController.swift b/litewallet/src/ViewControllers/SecurityCenter/SecurityCenterViewController.swift new file mode 100644 index 000000000..acb4809da --- /dev/null +++ b/litewallet/src/ViewControllers/SecurityCenter/SecurityCenterViewController.swift @@ -0,0 +1,210 @@ +import LocalAuthentication +import UIKit + +private let headerHeight: CGFloat = 222.0 +private let fadeStart: CGFloat = 185.0 +private let fadeEnd: CGFloat = 160.0 + +class SecurityCenterViewController: UIViewController, Subscriber { + var didTapPin: (() -> Void)? { + didSet { + pinCell.tap = didTapPin + } + } + + var didTapBiometrics: (() -> Void)? { + didSet { + biometricsCell.tap = didTapBiometrics + } + } + + var didTapPaperKey: (() -> Void)? { + didSet { paperKeyCell.tap = didTapPaperKey } + } + + init(store: Store, walletManager: WalletManager) { + self.store = store + self.walletManager = walletManager + header = ModalHeaderView(title: S.SecurityCenter.title.localize(), style: .light, faqInfo: (store, ArticleIds.nothing)) + + if #available(iOS 11.0, *) { + shield.tintColor = UIColor(named: "labelTextColor") + headerBackground.backgroundColor = UIColor(named: "mainColor") + } + + super.init(nibName: nil, bundle: nil) + } + + fileprivate var headerBackgroundHeight: NSLayoutConstraint? + private var headerBackground = UIView() + private let header: ModalHeaderView + fileprivate var shield = UIImageView(image: #imageLiteral(resourceName: "shield")) + private let scrollView = UIScrollView() + private let info = UILabel(font: .customBody(size: 16.0)) + private let pinCell = SecurityCenterCell(title: S.SecurityCenter.Cells.pinTitle.localize(), descriptionText: S.SecurityCenter.Cells.pinDescription.localize()) + private let biometricsCell = SecurityCenterCell(title: LAContext.biometricType() == .face ? S.SecurityCenter.Cells.faceIdTitle.localize() : S.SecurityCenter.Cells.touchIdTitle.localize(), descriptionText: S.SecurityCenter.Cells.touchIdDescription.localize()) + private let paperKeyCell = SecurityCenterCell(title: S.SecurityCenter.Cells.paperKeyTitle.localize(), descriptionText: S.SecurityCenter.Cells.paperKeyDescription.localize()) + private var separator = UIView(color: .secondaryShadow) + private let store: Store + private let walletManager: WalletManager + fileprivate var didViewAppear = false + + deinit { + store.unsubscribe(self) + } + + override func viewDidLoad() { + setupSubviewProperties() + addSubviews() + addConstraints() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + setPinAndPhraseChecks() + didViewAppear = false + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + didViewAppear = true + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + didViewAppear = false + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + private func setupSubviewProperties() { + if #available(iOS 11.0, *) { + guard let backgroundColor = UIColor(named: "lfBackgroundColor") + else { + NSLog("ERROR: Main color") + return + } + view.backgroundColor = backgroundColor + } else { + view.backgroundColor = .white + } + + header.closeCallback = { + self.dismiss(animated: true, completion: nil) + } + scrollView.alwaysBounceVertical = true + scrollView.panGestureRecognizer.delaysTouchesBegan = false + scrollView.delegate = self + info.text = S.SecurityCenter.info.localize() + info.numberOfLines = 0 + info.lineBreakMode = .byWordWrapping + header.backgroundColor = .clear + + setPinAndPhraseChecks() + store.subscribe(self, selector: { $0.isBiometricsEnabled != $1.isBiometricsEnabled }, callback: { + self.biometricsCell.isCheckHighlighted = $0.isBiometricsEnabled + }) + store.subscribe(self, selector: { $1.alert == .paperKeySet(callback: {}) + }, callback: { _ in + self.setPinAndPhraseChecks() // When paper phrase is confirmed, we need to update the check mark status + }) + } + + private func addSubviews() { + view.addSubview(scrollView) + scrollView.addSubview(headerBackground) + headerBackground.addSubview(header) + headerBackground.addSubview(shield) + scrollView.addSubview(pinCell) + scrollView.addSubview(biometricsCell) + scrollView.addSubview(paperKeyCell) + scrollView.addSubview(info) + } + + private func addConstraints() { + scrollView.constrain([ + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + headerBackground.constrain([ + headerBackground.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + headerBackground.topAnchor.constraint(equalTo: view.topAnchor), + headerBackground.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + headerBackground.widthAnchor.constraint(equalTo: view.widthAnchor), + ]) + headerBackgroundHeight = headerBackground.heightAnchor.constraint(equalToConstant: headerHeight) + headerBackground.constrain([headerBackgroundHeight]) + header.constrain([ + header.leadingAnchor.constraint(equalTo: view.leadingAnchor), + header.topAnchor.constraint(equalTo: headerBackground.topAnchor, constant: E.isIPhoneX ? 30.0 : 20.0), + header.trailingAnchor.constraint(equalTo: view.trailingAnchor), + header.heightAnchor.constraint(equalToConstant: C.Sizes.headerHeight), + ]) + shield.constrain([ + shield.centerXAnchor.constraint(equalTo: view.centerXAnchor), + shield.centerYAnchor.constraint(equalTo: headerBackground.centerYAnchor, constant: C.padding[3]), + ]) + info.constrain([ + info.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: C.padding[2]), + info.topAnchor.constraint(equalTo: headerBackground.bottomAnchor, constant: C.padding[2]), + info.widthAnchor.constraint(equalTo: view.widthAnchor, constant: -C.padding[4]), + ]) + scrollView.addSubview(separator) + separator.constrain([ + separator.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: C.padding[2]), + separator.topAnchor.constraint(equalTo: info.bottomAnchor, constant: C.padding[2]), + separator.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -C.padding[2]), + separator.heightAnchor.constraint(equalToConstant: 1.0), + ]) + pinCell.constrain([ + pinCell.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + pinCell.topAnchor.constraint(equalTo: separator.bottomAnchor), + pinCell.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + ]) + biometricsCell.constrain([ + biometricsCell.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + biometricsCell.topAnchor.constraint(equalTo: pinCell.bottomAnchor), + biometricsCell.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + ]) + paperKeyCell.constrain([ + paperKeyCell.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + paperKeyCell.topAnchor.constraint(equalTo: biometricsCell.bottomAnchor), + paperKeyCell.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + paperKeyCell.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -C.padding[2]), + ]) + + if !LAContext.isBiometricsAvailable { + biometricsCell.constrain([biometricsCell.heightAnchor.constraint(equalToConstant: 0.0)]) + } + } + + private func setPinAndPhraseChecks() { + pinCell.isCheckHighlighted = store.state.pinLength == 6 + paperKeyCell.isCheckHighlighted = !UserDefaults.walletRequiresBackup + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension SecurityCenterViewController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard didViewAppear else { return } // We don't want to be doing an stretchy header stuff during interactive pop gestures + let yOffset = scrollView.contentOffset.y + 20.0 + let newHeight = headerHeight - yOffset + headerBackgroundHeight?.constant = newHeight + + if newHeight < fadeStart { + let range = fadeStart - fadeEnd + let alpha = (newHeight - fadeEnd) / range + shield.alpha = max(alpha, 0.0) + } else { + shield.alpha = 1.0 + } + } +} diff --git a/litewallet/src/ViewControllers/SecurityCenter/UpdatePinViewController.swift b/litewallet/src/ViewControllers/SecurityCenter/UpdatePinViewController.swift new file mode 100644 index 000000000..ab00fe2c8 --- /dev/null +++ b/litewallet/src/ViewControllers/SecurityCenter/UpdatePinViewController.swift @@ -0,0 +1,289 @@ +import LocalAuthentication +import UIKit + +enum UpdatePinType { + case creationNoPhrase + case creationWithPhrase + case update +} + +class UpdatePinViewController: UIViewController, Subscriber { + // MARK: - Public + + var setPinSuccess: ((String) -> Void)? + var resetFromDisabledSuccess: (() -> Void)? + var resetFromDisabledWillSucceed: (() -> Void)? + + init(store: Store, walletManager: WalletManager, type: UpdatePinType, showsBackButton: Bool = true, phrase: String? = nil) + { + self.store = store + self.walletManager = walletManager + self.phrase = phrase + pinView = PinView(style: .create, length: store.state.pinLength) + self.showsBackButton = showsBackButton + self.type = type + super.init(nibName: nil, bundle: nil) + } + + // MARK: - Private + + private var header = UILabel.wrapping(font: .customBold(size: 26.0), color: .darkText) + private var instruction = UILabel.wrapping(font: .customBody(size: 14.0), color: .darkText) + private var caption = UILabel.wrapping(font: .customBody(size: 13.0), color: .secondaryGrayText) + private var pinView: PinView + private let pinPad = PinPadViewController(style: .clear, keyboardType: .pinPad, maxDigits: 0) + private let spacer = UIView() + private let store: Store + private let walletManager: WalletManager + private var step: Step = .verify { + didSet { + switch step { + case .verify: + instruction.text = isCreatingPin ? S.UpdatePin.createInstruction.localize() : S.UpdatePin.enterCurrent.localize() + caption.isHidden = true + case .new: + let instructionText = isCreatingPin ? S.UpdatePin.createInstruction.localize() : S.UpdatePin.enterNew.localize() + if instruction.text != instructionText { + instruction.pushNewText(instructionText) + } + header.text = S.UpdatePin.createTitle.localize() + caption.isHidden = false + case .confirmNew: + caption.isHidden = true + if isCreatingPin { + header.text = S.UpdatePin.createTitleConfirm.localize() + } else { + instruction.pushNewText(S.UpdatePin.reEnterNew.localize()) + } + } + } + } + + private var currentPin: String? + private var newPin: String? + private var phrase: String? + private let type: UpdatePinType + private var isCreatingPin: Bool { + return type != .update + } + + private let newPinLength = 6 + private let showsBackButton: Bool + + private enum Step { + case verify + case new + case confirmNew + } + + override func viewDidLoad() { + addSubviews() + addConstraints() + setData() + } + + private func addSubviews() { + view.addSubview(header) + view.addSubview(instruction) + view.addSubview(caption) + view.addSubview(pinView) + view.addSubview(spacer) + } + + private func addConstraints() { + header.constrain([ + header.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: C.padding[2]), + header.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + header.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + ]) + instruction.constrain([ + instruction.leadingAnchor.constraint(equalTo: header.leadingAnchor), + instruction.topAnchor.constraint(equalTo: header.bottomAnchor, constant: C.padding[2]), + instruction.trailingAnchor.constraint(equalTo: header.trailingAnchor), + ]) + pinView.constrain([ + pinView.centerYAnchor.constraint(equalTo: spacer.centerYAnchor), + pinView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + pinView.widthAnchor.constraint(equalToConstant: pinView.width), + pinView.heightAnchor.constraint(equalToConstant: pinView.itemSize), + ]) + + addChildViewController(pinPad, layout: { + pinPad.view.constrainBottomCorners(sidePadding: 0.0, bottomPadding: 0.0) + pinPad.view.constrain([ + pinPad.view.heightAnchor.constraint(equalToConstant: pinPad.height), + pinPad.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -C.padding[3]), + ]) + }) + spacer.constrain([ + spacer.topAnchor.constraint(equalTo: instruction.bottomAnchor), + spacer.bottomAnchor.constraint(equalTo: caption.topAnchor), + ]) + caption.constrain([ + caption.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + caption.bottomAnchor.constraint(equalTo: pinPad.view.topAnchor, constant: -C.padding[2]), + caption.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + ]) + } + + private func setData() { + caption.text = S.UpdatePin.caption.localize() + view.addSubview(spacer) + + header.textColor = .whiteTint + instruction.textColor = .whiteTint + caption.textColor = .whiteTint + if #available(iOS 11.0, *) { + guard let mainColor = UIColor(named: "mainColor") + else { + NSLog("ERROR: Custom color not found") + return + } + view.backgroundColor = mainColor + } else { + view.backgroundColor = .liteWalletBlue + } + header.text = isCreatingPin ? S.UpdatePin.createTitle.localize() : S.UpdatePin.updateTitle.localize() + instruction.text = isCreatingPin ? S.UpdatePin.createInstruction.localize() : S.UpdatePin.enterCurrent.localize() + + pinPad.ouputDidUpdate = { [weak self] text in + guard let step = self?.step else { return } + switch step { + case .verify: + self?.didUpdateForCurrent(pin: text) + case .new: + self?.didUpdateForNew(pin: text) + case .confirmNew: + self?.didUpdateForConfirmNew(pin: text) + } + } + + if isCreatingPin { + step = .new + caption.isHidden = false + } else { + caption.isHidden = true + } + + if !showsBackButton { + navigationItem.leftBarButtonItem = nil + navigationItem.hidesBackButton = true + } + } + + private func didUpdateForCurrent(pin: String) { + pinView.fill(pin.utf8.count) + if pin.utf8.count == store.state.pinLength { + if walletManager.authenticate(pin: pin) { + pushNewStep(.new) + currentPin = pin + replacePinView() + } else { + if walletManager.walletDisabledUntil > 0 { + dismiss(animated: true, completion: { + self.store.perform(action: RequireLogin()) + }) + } else { + clearAfterFailure() + } + } + } + } + + private func didUpdateForNew(pin: String) { + pinView.fill(pin.utf8.count) + if pin.utf8.count == newPinLength { + newPin = pin + pushNewStep(.confirmNew) + } + } + + private func didUpdateForConfirmNew(pin: String) { + guard let newPin = newPin else { return } + pinView.fill(pin.utf8.count) + if pin.utf8.count == newPinLength { + if pin == newPin { + didSetNewPin() + } else { + clearAfterFailure() + pushNewStep(.new) + } + } + } + + private func clearAfterFailure() { + pinPad.view.isUserInteractionEnabled = false + pinView.shake { [weak self] in + self?.pinPad.view.isUserInteractionEnabled = true + self?.pinView.fill(0) + } + pinPad.clear() + } + + private func replacePinView() { + pinView.removeFromSuperview() + pinView = PinView(style: .create, length: newPinLength) + view.addSubview(pinView) + pinView.constrain([ + pinView.centerYAnchor.constraint(equalTo: spacer.centerYAnchor), + pinView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + pinView.widthAnchor.constraint(equalToConstant: pinView.width), + pinView.heightAnchor.constraint(equalToConstant: pinView.itemSize), + ]) + } + + private func pushNewStep(_ newStep: Step) { + step = newStep + pinPad.clear() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.pinView.fill(0) + } + } + + private func didSetNewPin() { + DispatchQueue.walletQueue.async { [weak self] in + guard let newPin = self?.newPin else { return } + var success: Bool? = false + if let seedPhrase = self?.phrase { + success = self?.walletManager.forceSetPin(newPin: newPin, seedPhrase: seedPhrase) + } else if let currentPin = self?.currentPin { + success = self?.walletManager.changePin(newPin: newPin, pin: currentPin) + DispatchQueue.main.async { self?.store.trigger(name: .didUpgradePin) } + } else if self?.type == .creationNoPhrase { + success = self?.walletManager.forceSetPin(newPin: newPin) + } + + DispatchQueue.main.async { + if let success = success, success == true { + if self?.resetFromDisabledSuccess != nil { + self?.resetFromDisabledWillSucceed?() + self?.store.perform(action: SimpleReduxAlert.Show(.pinSet(callback: { [weak self] in + self?.dismiss(animated: true, completion: { + self?.resetFromDisabledSuccess?() + }) + }))) + } else { + self?.store.perform(action: SimpleReduxAlert.Show(.pinSet(callback: { [weak self] in + self?.setPinSuccess?(newPin) + if self?.type != .creationNoPhrase { + self?.parent?.dismiss(animated: true, completion: nil) + } + }))) + } + } else { + let alert = UIAlertController(title: S.UpdatePin.updateTitle.localize(), message: S.UpdatePin.setPinError.localize(), preferredStyle: .alert) + alert.addAction(UIAlertAction(title: S.Button.ok.localize(), style: .default, handler: { [weak self] _ in + self?.clearAfterFailure() + self?.pushNewStep(.new) + })) + self?.present(alert, animated: true, completion: nil) + } + } + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/SettingsViewController.swift b/litewallet/src/ViewControllers/SettingsViewController.swift new file mode 100644 index 000000000..7bf3321c9 --- /dev/null +++ b/litewallet/src/ViewControllers/SettingsViewController.swift @@ -0,0 +1,174 @@ +// +// SettingsViewController.swift +// breadwallet +// +// Created by Adrian Corscadden on 2017-03-30. +// Copyright © 2017 breadwallet LLC. All rights reserved. +// +import LocalAuthentication +import UIKit + +class SettingsViewController: UITableViewController, CustomTitleView { + init(sections: [String], rows: [String: [Setting]], optionalTitle _: String? = nil) { + self.sections = sections + if UserDefaults.isBiometricsEnabled { + self.rows = rows + } else { + var tempRows = rows + let biometricsLimit = LAContext.biometricType() == .face ? S.Settings.faceIdLimit.localize() : S.Settings.touchIdLimit.localize() + tempRows["Manage"] = tempRows["Manage"]?.filter { $0.title != biometricsLimit } + self.rows = tempRows + } + + customTitle = S.Settings.title.localize() // optionalTitle ?? S.Settings.title.localize() + titleLabel.text = S.Settings.title.localize() // "optionalTitle" // ?? S.Settings.title.localize() + super.init(style: .plain) + } + + private let sections: [String] + private var rows: [String: [Setting]] + private let cellIdentifier = "CellIdentifier" + internal var titleLabel = UILabel(font: .customBold(size: 26.0), color: .darkText) + let customTitle: String + private var walletIsEmpty = true + + override func viewDidLoad() { + let headerView = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 48.0)) + + guard let textColor = UIColor(named: "labelTextColor"), + let backGroundColor = UIColor(named: "lfBackgroundColor") + else { + NSLog("ERROR: Custom color not found") + return + } + + headerView.backgroundColor = backGroundColor + tableView.backgroundColor = backGroundColor + titleLabel = UILabel(font: .customBold(size: 26.0), color: textColor) + + headerView.addSubview(titleLabel) + titleLabel.constrain(toSuperviewEdges: UIEdgeInsets(top: 0, left: C.padding[2], bottom: 0, right: 0)) + tableView.register(SeparatorCell.self, forCellReuseIdentifier: cellIdentifier) + tableView.tableHeaderView = headerView + tableView.tableFooterView = UIView() + tableView.separatorStyle = .none + + addCustomTitle() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + } + + override func numberOfSections(in _: UITableView) -> Int { + return sections.count + } + + override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { + return rows[sections[section]]?.count ?? 0 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell + { + let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) + + if let setting = rows[sections[indexPath.section]]?[indexPath.row] { + cell.textLabel?.text = setting.title + cell.textLabel?.font = .customBody(size: 16.0) + + let label = UILabel(font: .customMedium(size: 14.0), color: .grayTextTint) + label.text = setting.accessoryText?() + label.sizeToFit() + cell.accessoryView = label + if sections[indexPath.section] == "About" { + cell.selectionStyle = .none + } + + if #available(iOS 11.0, *), + let textColor = UIColor(named: "labelTextColor") + { + cell.textLabel?.textColor = textColor + label.textColor = textColor + } else { + cell.textLabel?.textColor = .darkText + } + } + return cell + } + + override func tableView(_: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let view = UIView(frame: CGRect(x: 0, y: 0, width: self.view.bounds.width, height: 44)) + let label = UILabel(font: .customBold(size: 14.0), color: .grayTextTint) + let separator = UIView() + + if #available(iOS 11.0, *), + let backgroundColor = UIColor(named: "lfBackgroundColor"), + let labelTextColor = UIColor(named: "labelTextColor") + { + view.backgroundColor = backgroundColor + label.textColor = labelTextColor + } else { + view.backgroundColor = .whiteTint + } + + view.addSubview(label) + switch sections[section] { + case "About": + label.text = S.Settings.about.localize() + case "Wallet": + label.text = S.Settings.wallet.localize() + case "Manage": + label.text = S.Settings.manage.localize() + case "Support": + label.text = S.Settings.support.localize() + case "Blockchain": + label.text = S.Settings.blockchain.localize() + default: + label.text = "" + } + + separator.backgroundColor = .secondaryShadow + view.addSubview(separator) + separator.constrain([ + separator.leadingAnchor.constraint(equalTo: view.leadingAnchor), + separator.bottomAnchor.constraint(equalTo: view.bottomAnchor), + separator.trailingAnchor.constraint(equalTo: view.trailingAnchor), + separator.heightAnchor.constraint(equalToConstant: 1.0), + ]) + + label.constrain([ + label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + label.bottomAnchor.constraint(equalTo: separator.topAnchor, constant: -4.0), + ]) + + return view + } + + override func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { + if let setting = rows[sections[indexPath.section]]?[indexPath.row] { + setting.callback() + } + } + + override func tableView(_: UITableView, heightForHeaderInSection _: Int) -> CGFloat { + return 47.0 + } + + override func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat { + return 48.0 + } + + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + didScrollForCustomTitle(yOffset: scrollView.contentOffset.y) + } + + override func scrollViewWillEndDragging(_: UIScrollView, withVelocity _: CGPoint, targetContentOffset: UnsafeMutablePointer) + { + scrollViewWillEndDraggingForCustomTitle(yOffset: targetContentOffset.pointee.y) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/ShareDataViewController.swift b/litewallet/src/ViewControllers/ShareDataViewController.swift new file mode 100644 index 000000000..6308f30cd --- /dev/null +++ b/litewallet/src/ViewControllers/ShareDataViewController.swift @@ -0,0 +1,79 @@ +import UIKit + +class ShareDataViewController: UIViewController { + init(store: Store) { + self.store = store + super.init(nibName: nil, bundle: nil) + } + + private let store: Store + private let titleLabel = UILabel(font: .customBold(size: 26.0), color: .darkText) + private let body = UILabel.wrapping(font: .customBody(size: 16.0), color: .darkText) + private let label = UILabel(font: .customBold(size: 16.0), color: .darkText) + private let toggle = GradientSwitch() + private let separator = UIView(color: .secondaryShadow) + + override func viewDidLoad() { + addSubviews() + addConstraints() + setInitialData() + } + + private func addSubviews() { + view.addSubview(titleLabel) + view.addSubview(body) + view.addSubview(label) + view.addSubview(toggle) + view.addSubview(separator) + } + + private func addConstraints() { + titleLabel.constrain([ + titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: C.padding[2]), + ]) + body.constrain([ + body.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + body.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: C.padding[1]), + body.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + ]) + label.constrain([ + label.leadingAnchor.constraint(equalTo: body.leadingAnchor), + label.topAnchor.constraint(equalTo: body.bottomAnchor, constant: C.padding[3]), + ]) + toggle.constrain([ + toggle.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + toggle.centerYAnchor.constraint(equalTo: label.centerYAnchor), + ]) + separator.constrain([ + separator.leadingAnchor.constraint(equalTo: label.leadingAnchor), + separator.topAnchor.constraint(equalTo: toggle.bottomAnchor, constant: C.padding[2]), + separator.trailingAnchor.constraint(equalTo: toggle.trailingAnchor), + separator.heightAnchor.constraint(equalToConstant: 1.0), + ]) + } + + private func setInitialData() { + view.backgroundColor = .whiteTint + titleLabel.text = S.ShareData.header.localize() + body.text = S.ShareData.body.localize() + label.text = S.ShareData.toggleLabel.localize() + + if UserDefaults.hasAquiredShareDataPermission { + toggle.isOn = true + toggle.sendActions(for: .valueChanged) + } + + toggle.valueChanged = strongify(self) { myself in + UserDefaults.hasAquiredShareDataPermission = myself.toggle.isOn + if myself.toggle.isOn { + myself.store.trigger(name: .didEnableShareData) + } + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/StartPaperPhraseViewController.swift b/litewallet/src/ViewControllers/StartPaperPhraseViewController.swift new file mode 100644 index 000000000..e65eb9edc --- /dev/null +++ b/litewallet/src/ViewControllers/StartPaperPhraseViewController.swift @@ -0,0 +1,92 @@ +import UIKit + +class StartPaperPhraseViewController: UIViewController { + init(store: Store, callback: @escaping () -> Void) { + self.store = store + self.callback = callback + let buttonTitle = UserDefaults.walletRequiresBackup ? S.StartPaperPhrase.buttonTitle.localize() : S.StartPaperPhrase.againButtonTitle.localize() + button = ShadowButton(title: buttonTitle, type: .flatLitecoinBlue) + super.init(nibName: nil, bundle: nil) + explanation.textColor = .darkText + footer.textColor = .secondaryGrayText + } + + private let button: ShadowButton + private let illustration = UIImageView(image: #imageLiteral(resourceName: "PaperKey")) + private let pencil = UIImageView(image: #imageLiteral(resourceName: "Pencil")) + private var explanation = UILabel.wrapping(font: UIFont.barlowMedium(size: 20.0)) + private let store: Store + private let header = RadialGradientView(backgroundColor: .liteWalletBlue, offset: 64.0) + private var footer = UILabel.wrapping(font: .customBody(size: 13.0), color: .secondaryGrayText) + private let callback: () -> Void + + override func viewDidLoad() { + view.backgroundColor = .white + explanation.text = S.StartPaperPhrase.body.localize() + + addSubviews() + addConstraints() + button.tap = { [weak self] in + self?.callback() + } + if let writePaperPhraseDate = UserDefaults.writePaperPhraseDate { + let df = DateFormatter() + df.setLocalizedDateFormatFromTemplate("MMMM d, yyyy") + footer.text = String(format: S.StartPaperPhrase.date.localize(), df.string(from: writePaperPhraseDate)) + footer.textAlignment = .center + } + } + + private func addSubviews() { + view.addSubview(header) + header.addSubview(illustration) + illustration.addSubview(pencil) + view.addSubview(explanation) + view.addSubview(button) + view.addSubview(footer) + } + + private func addConstraints() { + header.constrainTopCorners(sidePadding: 0, topPadding: 0) + header.constrain([ + header.constraint(.height, constant: 220.0), + ]) + illustration.constrain([ + illustration.constraint(.width, constant: 64.0), + illustration.constraint(.height, constant: 84.0), + illustration.constraint(.centerX, toView: header, constant: nil), + illustration.constraint(.bottom, toView: header, constant: -C.padding[4]), + ]) + pencil.constrain([ + pencil.constraint(.width, constant: 32.0), + pencil.constraint(.height, constant: 32.0), + pencil.constraint(.leading, toView: illustration, constant: 44.0), + pencil.constraint(.top, toView: illustration, constant: -4.0), + ]) + explanation.constrain([ + explanation.constraint(toBottom: header, constant: C.padding[3]), + explanation.constraint(.leading, toView: view, constant: C.padding[2]), + explanation.constraint(.trailing, toView: view, constant: -C.padding[2]), + ]) + button.constrain([ + button.leadingAnchor.constraint(equalTo: footer.leadingAnchor), + button.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -C.padding[2]), + button.trailingAnchor.constraint(equalTo: footer.trailingAnchor), + button.constraint(.height, constant: C.Sizes.buttonHeight), + ]) + footer.constrain([ + footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -C.padding[2]), + footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + ]) + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/StartViewController.swift b/litewallet/src/ViewControllers/StartViewController.swift new file mode 100644 index 000000000..490602b9b --- /dev/null +++ b/litewallet/src/ViewControllers/StartViewController.swift @@ -0,0 +1,122 @@ +import UIKit + +class StartViewController: UIViewController { + // MARK: - Public + + init(store: Store, didTapCreate: @escaping () -> Void, didTapRecover: @escaping () -> Void) { + self.store = store + self.didTapRecover = didTapRecover + self.didTapCreate = didTapCreate + super.init(nibName: nil, bundle: nil) + } + + // MARK: - Private + + private let message = UILabel(font: .barlowLight(size: 22), color: .whiteTint) + + private let create = ShadowButton(title: S.StartViewController.createButton.localize(), type: .flatWhite) + private let recover = ShadowButton(title: S.StartViewController.recoverButton.localize(), type: .flatLitecoinBlue) + private let store: Store + private let didTapRecover: () -> Void + private let didTapCreate: () -> Void + private let backgroundView: UIView = { + let view = UIView() + view.backgroundColor = .liteWalletBlue + return view + }() + + private var logo: UIImageView = { + let image = UIImageView(image: UIImage(named: "new-logotype-white")) + image.contentMode = .scaleAspectFit + image.alpha = 0.8 + return image + }() + + private let versionLabel = UILabel(font: .barlowMedium(size: 14), color: .transparentWhite) + + override func viewDidLoad() { + view.backgroundColor = .white + setData() + addSubviews() + addConstraints() + addButtonActions() + } + + private func setData() { + message.lineBreakMode = .byWordWrapping + message.numberOfLines = 0 + message.textAlignment = .center + versionLabel.text = AppVersion.string + versionLabel.textAlignment = .right + message.textColor = .white + versionLabel.textColor = .white + + if #available(iOS 11.0, *) { + guard let mainColor = UIColor(named: "mainColor") + else { + NSLog("ERROR: Custom color not found") + return + } + view.backgroundColor = mainColor + } else { + view.backgroundColor = .liteWalletBlue + } + } + + private func addSubviews() { + view.addSubview(backgroundView) + view.addSubview(logo) + view.addSubview(message) + view.addSubview(create) + view.addSubview(recover) + view.addSubview(versionLabel) + } + + private func addConstraints() { + backgroundView.constrain(toSuperviewEdges: nil) + + logo.constrain([ + logo.topAnchor.constraint(equalTo: view.centerYAnchor, constant: -120), + logo.centerXAnchor.constraint(equalTo: view.centerXAnchor), + logo.constraint(.height, constant: 45), + logo.constraint(.width, constant: 201), + ]) + message.constrain([ + message.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 50), + message.topAnchor.constraint(equalTo: logo.bottomAnchor, constant: C.padding[3]), + message.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -50), + ]) + recover.constrain([ + recover.constraint(.leading, toView: view, constant: C.padding[2]), + recover.constraint(.bottom, toView: view, constant: -60), + recover.constraint(.trailing, toView: view, constant: -C.padding[2]), + recover.constraint(.height, constant: C.Sizes.buttonHeight), + ]) + create.constrain([ + create.constraint(toTop: recover, constant: -C.padding[2]), + create.constraint(.centerX, toView: recover, constant: nil), + create.constraint(.width, toView: recover, constant: nil), + create.constraint(.height, constant: C.Sizes.buttonHeight), + ]) + versionLabel.constrain([ + versionLabel.constraint(.top, toView: view, constant: 30), + versionLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + versionLabel.widthAnchor.constraint(equalToConstant: 120.0), + versionLabel.heightAnchor.constraint(equalToConstant: 44.0), + ]) + } + + private func addButtonActions() { + recover.tap = didTapRecover + create.tap = didTapCreate + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/StartWipeWalletViewController.swift b/litewallet/src/ViewControllers/StartWipeWalletViewController.swift new file mode 100644 index 000000000..f69aa647f --- /dev/null +++ b/litewallet/src/ViewControllers/StartWipeWalletViewController.swift @@ -0,0 +1,85 @@ +import UIKit + +class StartWipeWalletViewController: UIViewController { + init(didTapNext: @escaping () -> Void) { + self.didTapNext = didTapNext + super.init(nibName: nil, bundle: nil) + } + + private let didTapNext: () -> Void + private let header = RadialGradientView(backgroundColor: .blue, offset: 64.0) + private let illustration = UIImageView(image: #imageLiteral(resourceName: "RestoreIllustration")) + private let message = UILabel.wrapping(font: .customBody(size: 16.0), color: .darkText) + private let warning = UILabel.wrapping(font: .customBody(size: 16.0), color: .darkText) + private let button = ShadowButton(title: S.RecoverWallet.next.localize(), type: .primary) + private let bullet = UIImageView(image: #imageLiteral(resourceName: "deletecircle")) + + override func viewDidLoad() { + addSubviews() + addConstraints() + setInitialData() + } + + private func addSubviews() { + view.addSubview(header) + header.addSubview(illustration) + view.addSubview(message) + view.addSubview(warning) + view.addSubview(bullet) + view.addSubview(button) + } + + private func addConstraints() { + header.constrainTopCorners(sidePadding: 0, topPadding: 0) + header.constrain([ + header.constraint(.height, constant: 220.0), + ]) + illustration.constrain([ + illustration.constraint(.width, constant: 64.0), + illustration.constraint(.height, constant: 84.0), + illustration.constraint(.centerX, toView: header, constant: 0.0), + illustration.constraint(.centerY, toView: header, constant: C.padding[3]), + ]) + message.constrain([ + message.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + message.topAnchor.constraint(equalTo: header.bottomAnchor, constant: C.padding[2]), + message.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + ]) + bullet.constrain([ + bullet.leadingAnchor.constraint(equalTo: message.leadingAnchor), + bullet.topAnchor.constraint(equalTo: message.bottomAnchor, constant: C.padding[4]), + bullet.widthAnchor.constraint(equalToConstant: 16.0), + bullet.heightAnchor.constraint(equalToConstant: 16.0), + ]) + warning.constrain([ + warning.leadingAnchor.constraint(equalTo: bullet.trailingAnchor, constant: C.padding[2]), + warning.topAnchor.constraint(equalTo: bullet.topAnchor, constant: 0.0), + warning.trailingAnchor.constraint(equalTo: message.trailingAnchor), + ]) + button.constrain([ + button.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[3]), + button.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -C.padding[4]), + button.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[3]), + button.constraint(.height, constant: C.Sizes.buttonHeight), + ]) + } + + private func setInitialData() { + view.backgroundColor = .white + illustration.contentMode = .scaleAspectFill + message.text = S.WipeWallet.startMessage.localize() + warning.text = S.WipeWallet.startWarning.localize() + button.tap = { [weak self] in + self?.didTapNext() + } + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/VerifyPinViewController.swift b/litewallet/src/ViewControllers/VerifyPinViewController.swift new file mode 100644 index 000000000..064b12323 --- /dev/null +++ b/litewallet/src/ViewControllers/VerifyPinViewController.swift @@ -0,0 +1,157 @@ +import LocalAuthentication +import UIKit + +typealias VerifyPinCallback = (String, UIViewController) -> Bool + +protocol ContentBoxPresenter { + var contentBox: UIView { get } + var blurView: UIVisualEffectView { get } + var effect: UIBlurEffect { get } +} + +class VerifyPinViewController: UIViewController, ContentBoxPresenter { + init(bodyText: String, pinLength: Int, callback: @escaping VerifyPinCallback) { + self.bodyText = bodyText + self.callback = callback + self.pinLength = pinLength + pinView = PinView(style: .create, length: pinLength) + super.init(nibName: nil, bundle: nil) + } + + var didCancel: (() -> Void)? + let blurView = UIVisualEffectView() + let effect = UIBlurEffect(style: .dark) + let contentBox = UIView() + private let callback: VerifyPinCallback + private let pinPad = PinPadViewController(style: .clear, keyboardType: .pinPad, maxDigits: 0) + private let titleLabel = UILabel(font: .customBold(size: 17.0), color: .white) + private let body = UILabel(font: .customBody(size: 14.0), color: .white) + private let pinView: PinView + private let toolbar = UIView(color: .whiteTint) + private let toolbarBorder = UIView(color: .whiteTint) + private let cancel = UIButton(type: .system) + private let bodyText: String + private let pinLength: Int + + override func viewDidLoad() { + addSubviews() + addConstraints() + setupSubviews() + } + + private func addSubviews() { + view.addSubview(contentBox) + view.addSubview(toolbarBorder) + view.addSubview(toolbar) + toolbar.addSubview(cancel) + + contentBox.addSubview(titleLabel) + contentBox.addSubview(body) + contentBox.addSubview(pinView) + addChildViewController(pinPad, layout: { + pinPad.view.constrain([ + pinPad.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + pinPad.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: LAContext.biometricType() == .face ? -C.padding[3] : 0.0), + pinPad.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + pinPad.view.heightAnchor.constraint(equalToConstant: pinPad.height), + ]) + }) + } + + private func addConstraints() { + contentBox.constrain([ + contentBox.centerXAnchor.constraint(equalTo: view.centerXAnchor), + contentBox.bottomAnchor.constraint(equalTo: pinPad.view.topAnchor, constant: -C.padding[12]), + contentBox.widthAnchor.constraint(equalToConstant: 256.0), + ]) + titleLabel.constrainTopCorners(sidePadding: C.padding[2], topPadding: C.padding[2]) + body.constrain([ + body.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + body.topAnchor.constraint(equalTo: titleLabel.bottomAnchor), + body.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + ]) + pinView.constrain([ + pinView.topAnchor.constraint(equalTo: body.bottomAnchor, constant: C.padding[2]), + pinView.centerXAnchor.constraint(equalTo: body.centerXAnchor), + pinView.widthAnchor.constraint(equalToConstant: pinView.width), + pinView.heightAnchor.constraint(equalToConstant: pinView.itemSize), + pinView.bottomAnchor.constraint(equalTo: contentBox.bottomAnchor, constant: -C.padding[2]), + ]) + toolbar.constrain([ + toolbar.leadingAnchor.constraint(equalTo: pinPad.view.leadingAnchor), + toolbar.bottomAnchor.constraint(equalTo: pinPad.view.topAnchor), + toolbar.trailingAnchor.constraint(equalTo: pinPad.view.trailingAnchor), + toolbar.heightAnchor.constraint(equalToConstant: 44.0), + ]) + cancel.constrain([ + cancel.centerYAnchor.constraint(equalTo: toolbar.centerYAnchor), + cancel.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor, constant: -C.padding[2]), + ]) + toolbarBorder.constrain([ + toolbarBorder.leadingAnchor.constraint(equalTo: pinPad.view.leadingAnchor), + toolbarBorder.bottomAnchor.constraint(equalTo: toolbar.topAnchor), + toolbarBorder.trailingAnchor.constraint(equalTo: pinPad.view.trailingAnchor), + toolbarBorder.heightAnchor.constraint(equalToConstant: 1.0), + ]) + } + + private func setupSubviews() { + contentBox.backgroundColor = .liteWalletBlue + contentBox.layer.cornerRadius = 8.0 + contentBox.layer.borderWidth = 1.0 + contentBox.layer.borderColor = UIColor.secondaryShadow.cgColor + contentBox.layer.shadowColor = UIColor.black.cgColor + contentBox.layer.shadowOpacity = 0.15 + contentBox.layer.shadowRadius = 4.0 + contentBox.layer.shadowOffset = .zero + + toolbar.backgroundColor = .clear + + titleLabel.text = S.VerifyPin.title.localize() + body.text = bodyText + body.numberOfLines = 0 + body.lineBreakMode = .byWordWrapping + + pinPad.ouputDidUpdate = { [weak self] output in + guard let myself = self else { return } + let attemptLength = output.utf8.count + myself.pinView.fill(attemptLength) + myself.pinPad.isAppendingDisabled = attemptLength < myself.pinLength ? false : true + + if attemptLength == myself.pinLength { + if !myself.callback(output, myself) { + myself.authenticationFailed() + } else { + NSLog("FAILED") + } + } else { + NSLog("FAILED") + } + } + cancel.tap = { [weak self] in + self?.didCancel?() + self?.dismiss(animated: true, completion: nil) + } + cancel.setTitle(S.Button.cancel.localize(), for: .normal) + cancel.tintColor = .white + view.backgroundColor = .clear + } + + private func authenticationFailed() { + pinPad.view.isUserInteractionEnabled = false + pinView.shake { [weak self] in + self?.pinPad.view.isUserInteractionEnabled = true + self?.pinView.fill(0) + } + pinPad.clear() + } + + override var prefersStatusBarHidden: Bool { + return true + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewControllers/ViewControllerTransitions/DismissLoginAnimator.swift b/litewallet/src/ViewControllers/ViewControllerTransitions/DismissLoginAnimator.swift new file mode 100644 index 000000000..797f4330a --- /dev/null +++ b/litewallet/src/ViewControllers/ViewControllerTransitions/DismissLoginAnimator.swift @@ -0,0 +1,18 @@ +import UIKit + +class DismissLoginAnimator: NSObject, UIViewControllerAnimatedTransitioning { + func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.4 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard transitionContext.isAnimated else { return } + let duration = transitionDuration(using: transitionContext) + guard let fromView = transitionContext.view(forKey: .from) else { assertionFailure("Missing from view"); return } + UIView.animate(withDuration: duration, animations: { + fromView.alpha = 0.0 + }) { _ in + transitionContext.completeTransition(true) + } + } +} diff --git a/litewallet/src/ViewControllers/ViewControllerTransitions/DismissModalAnimator.swift b/litewallet/src/ViewControllers/ViewControllerTransitions/DismissModalAnimator.swift new file mode 100644 index 000000000..e0669b4a3 --- /dev/null +++ b/litewallet/src/ViewControllers/ViewControllerTransitions/DismissModalAnimator.swift @@ -0,0 +1,23 @@ +import UIKit + +// TODO: - figure out who should own this +let blurView = UIVisualEffectView() + +class DismissModalAnimator: NSObject, UIViewControllerAnimatedTransitioning { + func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.4 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard transitionContext.isAnimated else { return } + let duration = transitionDuration(using: transitionContext) + guard let fromView = transitionContext.view(forKey: .from) else { assertionFailure("Missing from view"); return } + + UIView.animate(withDuration: duration, animations: { + blurView.alpha = 0.0 // Preferrably, this would animatate .effect, but it's not playing nicely with UIPercentDrivenInteractiveTransition + fromView.frame = fromView.frame.offsetBy(dx: 0, dy: fromView.frame.height) + }, completion: { _ in + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + }) + } +} diff --git a/litewallet/src/ViewControllers/ViewControllerTransitions/LoginTransitionDelegate.swift b/litewallet/src/ViewControllers/ViewControllerTransitions/LoginTransitionDelegate.swift new file mode 100644 index 000000000..260a9a72f --- /dev/null +++ b/litewallet/src/ViewControllers/ViewControllerTransitions/LoginTransitionDelegate.swift @@ -0,0 +1,8 @@ +import UIKit + +class LoginTransitionDelegate: NSObject, UIViewControllerTransitioningDelegate { + func animationController(forDismissed _: UIViewController) -> UIViewControllerAnimatedTransitioning? + { + return DismissLoginAnimator() + } +} diff --git a/litewallet/src/ViewControllers/ViewControllerTransitions/ModalTransitionDelegate.swift b/litewallet/src/ViewControllers/ViewControllerTransitions/ModalTransitionDelegate.swift new file mode 100644 index 000000000..6bd995a3d --- /dev/null +++ b/litewallet/src/ViewControllers/ViewControllerTransitions/ModalTransitionDelegate.swift @@ -0,0 +1,105 @@ +import UIKit + +enum ModalType { + case regular + case transactionDetail +} + +class ModalTransitionDelegate: NSObject, Subscriber { + // MARK: - Public + + init(type: ModalType, store: Store) { + self.type = type + self.store = store + super.init() + } + + func reset() { + isInteractive = false + presentedViewController = nil + if let panGr = panGestureRecognizer { + LWAnalytics.logEventWithParameters(itemName: ._20210427_HCIEEH) + UIApplication.shared.windows.filter { $0.isKeyWindow }.first?.removeGestureRecognizer(panGr) + } + store.trigger(name: .showStatusBar) + } + + var shouldDismissInteractively = true + + // MARK: - Private + + fileprivate let type: ModalType + fileprivate let store: Store + fileprivate var isInteractive: Bool = false + fileprivate let interactiveTransition = UIPercentDrivenInteractiveTransition() + fileprivate var presentedViewController: UIViewController? + fileprivate var panGestureRecognizer: UIPanGestureRecognizer? + + private var yVelocity: CGFloat = 0.0 + private var progress: CGFloat = 0.0 + private let velocityThreshold: CGFloat = 50.0 + private let progressThreshold: CGFloat = 0.5 + + @objc fileprivate func didUpdate(gr: UIPanGestureRecognizer) { + guard shouldDismissInteractively else { return } + switch gr.state { + case .began: + isInteractive = true + presentedViewController?.dismiss(animated: true, completion: nil) + case .changed: + guard let vc = presentedViewController else { break } + let yOffset = gr.translation(in: vc.view).y + let progress = yOffset / vc.view.bounds.height + yVelocity = gr.velocity(in: vc.view).y + self.progress = progress + interactiveTransition.update(progress) + case .cancelled: + reset() + interactiveTransition.cancel() + case .ended: + if transitionShouldFinish { + reset() + interactiveTransition.finish() + } else { + isInteractive = false + interactiveTransition.cancel() + } + case .failed: + break + case .possible: + break + } + } + + private var transitionShouldFinish: Bool { + if progress > progressThreshold || yVelocity > velocityThreshold { + return true + } else { + return false + } + } +} + +extension ModalTransitionDelegate: UIViewControllerTransitioningDelegate { + func animationController(forPresented presented: UIViewController, presenting _: UIViewController, source _: UIViewController) -> UIViewControllerAnimatedTransitioning? + { + presentedViewController = presented + return PresentModalAnimator(shouldCoverBottomGap: type == .regular, completion: { + let panGr = UIPanGestureRecognizer(target: self, action: #selector(ModalTransitionDelegate.didUpdate(gr:))) + + LWAnalytics.logEventWithParameters(itemName: ._20210427_HCIEEH) + UIApplication.shared.windows.filter { $0.isKeyWindow }.first?.removeGestureRecognizer(panGr) + self.panGestureRecognizer = panGr + }) + } + + func animationController(forDismissed _: UIViewController) -> UIViewControllerAnimatedTransitioning? + { + return DismissModalAnimator() + } + + func interactionControllerForDismissal(using _: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? + { + return isInteractive ? interactiveTransition : nil + } +} diff --git a/litewallet/src/ViewControllers/ViewControllerTransitions/PinTransitioningDelegate.swift b/litewallet/src/ViewControllers/ViewControllerTransitions/PinTransitioningDelegate.swift new file mode 100644 index 000000000..de8527564 --- /dev/null +++ b/litewallet/src/ViewControllers/ViewControllerTransitions/PinTransitioningDelegate.swift @@ -0,0 +1,94 @@ +import UIKit + +private let duration: TimeInterval = 0.4 + +class TransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate { + var shouldShowMaskView = true + + func animationController(forPresented _: UIViewController, presenting _: UIViewController, source _: UIViewController) -> UIViewControllerAnimatedTransitioning? + { + return PresentGenericAnimator(shouldShowMaskView: shouldShowMaskView) + } + + func animationController(forDismissed _: UIViewController) -> UIViewControllerAnimatedTransitioning? + { + return DismissGenericAnimator() + } +} + +class PresentGenericAnimator: NSObject, UIViewControllerAnimatedTransitioning { + init(shouldShowMaskView: Bool) { + self.shouldShowMaskView = shouldShowMaskView + } + + private let shouldShowMaskView: Bool + + func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval { + return duration + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + let duration = transitionDuration(using: transitionContext) + let container = transitionContext.containerView + guard let toView = transitionContext.view(forKey: .to) else { return } + guard let toVc = transitionContext.viewController(forKey: .to) as? ContentBoxPresenter else { return } + + let blurView = toVc.blurView + blurView.frame = container.frame + blurView.effect = nil + container.addSubview(blurView) + + let fromFrame = container.frame + let maskView = UIView(frame: CGRect(x: 0, y: fromFrame.height, width: fromFrame.width, height: 40.0)) + maskView.backgroundColor = .whiteTint + if shouldShowMaskView { + container.addSubview(maskView) + } + + let scaleFactor: CGFloat = 0.1 + let deltaX = toVc.contentBox.frame.width * (1 - scaleFactor) + let deltaY = toVc.contentBox.frame.height * (1 - scaleFactor) + let scale = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor) + toVc.contentBox.transform = scale.translatedBy(x: -deltaX, y: deltaY / 2.0) + + let finalToViewFrame = toView.frame + toView.frame = toView.frame.offsetBy(dx: 0, dy: toView.frame.height) + container.addSubview(toView) + + UIView.spring(duration, animations: { + maskView.frame = CGRect(x: 0, y: fromFrame.height - 30.0, width: fromFrame.width, height: 40.0) + blurView.effect = toVc.effect + toView.frame = finalToViewFrame + toVc.contentBox.transform = .identity + }, completion: { _ in + maskView.removeFromSuperview() + transitionContext.completeTransition(true) + }) + } +} + +class DismissGenericAnimator: NSObject, UIViewControllerAnimatedTransitioning { + func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval { + return duration + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + let duration = transitionDuration(using: transitionContext) + guard let fromView = transitionContext.view(forKey: .from) else { assertionFailure("Missing from view"); return } + guard let fromVc = transitionContext.viewController(forKey: .from) as? ContentBoxPresenter else { return } + + UIView.animate(withDuration: duration, animations: { + fromVc.blurView.effect = nil + fromView.frame = fromView.frame.offsetBy(dx: 0, dy: fromView.frame.height) + + let scaleFactor: CGFloat = 0.1 + let deltaX = fromVc.contentBox.frame.width * (1 - scaleFactor) + let deltaY = fromVc.contentBox.frame.height * (1 - scaleFactor) + let scale = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor) + fromVc.contentBox.transform = scale.translatedBy(x: -deltaX, y: deltaY / 2.0) + + }, completion: { _ in + transitionContext.completeTransition(true) + }) + } +} diff --git a/litewallet/src/ViewControllers/ViewControllerTransitions/PresentModalAnimator.swift b/litewallet/src/ViewControllers/ViewControllerTransitions/PresentModalAnimator.swift new file mode 100644 index 000000000..9d478a2b5 --- /dev/null +++ b/litewallet/src/ViewControllers/ViewControllerTransitions/PresentModalAnimator.swift @@ -0,0 +1,60 @@ +import UIKit + +class PresentModalAnimator: NSObject { + // MARK: - Public + + let heightOffset: CGFloat = 40.0 + init(shouldCoverBottomGap: Bool, completion: @escaping () -> Void) { + self.completion = completion + self.shouldCoverBottomGap = shouldCoverBottomGap + } + + // MARK: - Private + + fileprivate let completion: () -> Void + fileprivate let shouldCoverBottomGap: Bool +} + +extension PresentModalAnimator: UIViewControllerAnimatedTransitioning { + func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.4 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard transitionContext.isAnimated else { return } + let duration = transitionDuration(using: transitionContext) + guard let toView = transitionContext.view(forKey: .to) else { assertionFailure("Missing to view"); return } + let container = transitionContext.containerView + + blurView.frame = container.frame + blurView.effect = nil + blurView.alpha = 1.0 + container.addSubview(blurView) + + // This mask view is placed below the bottom of the modal being presented. + // It needs to be there to cover up the gap left below the modal during the + // spring animation. It looks weird if it isn't there. + let fromFrame = container.frame + var maskView: UIView? + if shouldCoverBottomGap { + maskView = UIView(frame: CGRect(x: 0, y: fromFrame.height, width: fromFrame.width, height: heightOffset)) + maskView?.backgroundColor = .white + container.addSubview(maskView!) + } + + let finalToViewFrame = toView.frame + toView.frame = toView.frame.offsetBy(dx: 0, dy: toView.frame.height) + container.addSubview(toView) + + UIView.spring(duration, animations: { + // fromFrame.height - 30.0 + maskView?.frame = CGRect(x: 0, y: fromFrame.height, width: fromFrame.width, height: self.heightOffset) + blurView.effect = UIBlurEffect(style: .dark) + toView.frame = finalToViewFrame + }, completion: { _ in + transitionContext.completeTransition(true) + maskView?.removeFromSuperview() + self.completion() + }) + } +} diff --git a/litewallet/src/ViewControllers/WritePaperPhraseViewController.swift b/litewallet/src/ViewControllers/WritePaperPhraseViewController.swift new file mode 100644 index 000000000..383ab57e2 --- /dev/null +++ b/litewallet/src/ViewControllers/WritePaperPhraseViewController.swift @@ -0,0 +1,185 @@ +import UIKit + +class WritePaperPhraseViewController: UIViewController { + private let store: Store + private let walletManager: WalletManager + private let pin: String + private let label = UILabel.wrapping(font: UIFont.customBody(size: 16.0)) + private let stepLabel = UILabel.wrapping(font: UIFont.customMedium(size: 16.0)) + private let header = RadialGradientView(backgroundColor: .liteWalletBlue) + + private lazy var phraseViews: [PhraseView] = { + guard let phraseString = self.walletManager.seedPhrase(pin: self.pin) else { return [] } + let words = phraseString.components(separatedBy: " ") + return words.map { PhraseView(phrase: $0) } + }() + + // This is awkwardly named because nextResponder is now named next is swift 3 :(, + private let proceed = ShadowButton(title: S.WritePaperPhrase.next.localize(), type: .secondary) + private let previous = ShadowButton(title: S.WritePaperPhrase.previous.localize(), type: .secondary) + private var proceedWidth: NSLayoutConstraint? + private var previousWidth: NSLayoutConstraint? + + private var phraseOffscreenOffset: CGFloat { + return view.bounds.width / 2.0 + PhraseView.defaultSize.width / 2.0 + } + + private var currentPhraseIndex = 0 { + didSet { + stepLabel.text = String(format: S.WritePaperPhrase.step.localize(), currentPhraseIndex + 1, phraseViews.count) + } + } + + var lastWordSeen: (() -> Void)? + + init(store: Store, walletManager: WalletManager, pin: String, callback: @escaping () -> Void) { + self.store = store + self.walletManager = walletManager + self.pin = pin + self.callback = callback + super.init(nibName: nil, bundle: nil) + } + + private let callback: () -> Void + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func viewDidLoad() { + view.backgroundColor = .white + + label.text = S.WritePaperPhrase.instruction.localize() + label.textAlignment = .center + label.textColor = .white + + stepLabel.text = String(format: S.WritePaperPhrase.step.localize(), 1, phraseViews.count) + stepLabel.textAlignment = .center + + stepLabel.textColor = UIColor(white: 170.0 / 255.0, alpha: 1.0) + + addSubviews() + addConstraints() + addButtonTargets() + + NotificationCenter.default.addObserver(forName: UIScene.willDeactivateNotification, + object: nil, + queue: nil) { [weak self] _ in + self?.dismiss(animated: true, completion: nil) + } + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + private func addSubviews() { + view.addSubview(header) + header.addSubview(label) + view.addSubview(stepLabel) + view.addSubview(proceed) + view.addSubview(previous) + phraseViews.forEach { view.addSubview($0) } + } + + private func addConstraints() { + header.constrainTopCorners(sidePadding: 0, topPadding: 0) + header.constrain([ + header.constraint(.height, constant: 152.0), + ]) + label.constrainBottomCorners(sidePadding: C.padding[3], bottomPadding: C.padding[2]) + + phraseViews.enumerated().forEach { index, phraseView in + // The first phrase should initially be on the screen + let constant = index == 0 ? 0.0 : phraseOffscreenOffset + let xConstraint = NSLayoutConstraint(item: phraseView, attribute: .centerX, relatedBy: .equal, toItem: view, attribute: .centerX, multiplier: 1.0, constant: constant) + phraseView.xConstraint = xConstraint + phraseView.constrain([ + phraseView.constraint(.width, constant: PhraseView.defaultSize.width), + phraseView.constraint(.height, constant: PhraseView.defaultSize.height), + phraseView.constraint(.centerY, toView: view, constant: 0.0), + xConstraint, + ]) + } + + stepLabel.constrain([ + NSLayoutConstraint(item: stepLabel, attribute: .top, relatedBy: .equal, toItem: view, attribute: .centerY, multiplier: 1.0, constant: PhraseView.defaultSize.height / 2.0 + C.padding[1]), + stepLabel.constraint(.centerX, toView: view, constant: 0.0), + stepLabel.constraint(.width, constant: 200.0), // The transitions are smoother if this view is forced to be wider than it needs to be + ]) + + proceedWidth = proceed.constraint(.width, toView: view, constant: -C.padding[2] * 2) + proceed.constrain([ + proceed.constraint(.trailing, toView: view, constant: -C.padding[2]), + proceed.constraint(.height, constant: C.Sizes.buttonHeight), + proceed.constraint(.bottom, toView: view, constant: -(C.padding[4] + C.Sizes.buttonHeight)), + proceedWidth!, + ]) + + previousWidth = previous.constraint(.width, toView: view, constant: -view.bounds.width) + previous.constrain([ + previous.constraint(.leading, toView: view, constant: C.padding[2]), + previous.constraint(.height, constant: C.Sizes.buttonHeight), + previous.constraint(.bottom, toView: view, constant: -(C.padding[4] + C.Sizes.buttonHeight)), + previousWidth!, + ]) + } + + private func addButtonTargets() { + proceed.addTarget(self, action: #selector(proceedTapped), for: .touchUpInside) + previous.addTarget(self, action: #selector(previousTapped), for: .touchUpInside) + } + + @objc private func proceedTapped() { + guard currentPhraseIndex < phraseViews.count - 1 else { callback(); return } + if currentPhraseIndex == 0 { + showBothButtons() + } + transitionTo(isNext: true) + } + + @objc private func previousTapped() { + guard currentPhraseIndex > 0 else { return } + if currentPhraseIndex == 1 { + showOneButton() + } + transitionTo(isNext: false) + } + + private func transitionTo(isNext: Bool) { + let viewToHide = phraseViews[currentPhraseIndex] + let viewToShow = phraseViews[isNext ? currentPhraseIndex + 1 : currentPhraseIndex - 1] + if isNext { + currentPhraseIndex += 1 + } else { + currentPhraseIndex -= 1 + } + + UIView.spring(0.6, animations: { + viewToHide.xConstraint?.constant = isNext ? -self.phraseOffscreenOffset : self.phraseOffscreenOffset + viewToShow.xConstraint?.constant = 0 + self.view.layoutIfNeeded() + }, completion: { _ in }) + } + + private func showBothButtons() { + UIView.animate(withDuration: 0.4) { + self.proceedWidth?.constant = -self.view.bounds.width / 2.0 - C.padding[2] - C.padding[1] / 2.0 + self.previousWidth?.constant = -self.view.bounds.width / 2.0 - C.padding[2] - C.padding[1] / 2.0 + self.view.layoutIfNeeded() + } + } + + private func showOneButton() { + UIView.animate(withDuration: 0.4) { + self.proceedWidth?.constant = -C.padding[2] * 2 + self.previousWidth?.constant = -self.view.bounds.width + self.view.layoutIfNeeded() + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/ViewModels/Amount.swift b/litewallet/src/ViewModels/Amount.swift new file mode 100644 index 000000000..99f0044b7 --- /dev/null +++ b/litewallet/src/ViewModels/Amount.swift @@ -0,0 +1,166 @@ +import Foundation + +struct Amount { + // MARK: - Public + + let amount: UInt64 // amount in satoshis + let rate: Rate + let maxDigits: Int + + var amountForLtcFormat: Double { + var decimal = Decimal(self.amount) + var amount: Decimal = 0.0 + NSDecimalMultiplyByPowerOf10(&amount, &decimal, Int16(-maxDigits), .up) + return NSDecimalNumber(decimal: amount).doubleValue + } + + var localAmount: Double { + return Double(amount) / 100_000_000.0 * rate.rate + } + + var bits: String { + var decimal = Decimal(self.amount) + var amount: Decimal = 0.0 + NSDecimalMultiplyByPowerOf10(&amount, &decimal, Int16(-maxDigits), .up) + let number = NSDecimalNumber(decimal: amount) + guard let string = ltcFormat.string(from: number) else { return "" } + return string + } + + var localCurrency: String { + guard let string = localFormat.string(from: Double(amount) / 100_000_000.0 * rate.rate as NSNumber) else { return "" } + return string + } + + func string(forLocal local: Locale) -> String { + let format = NumberFormatter() + format.locale = local + format.isLenient = true + format.numberStyle = .currency + format.generatesDecimalNumbers = true + format.negativePrefix = "-" + guard let string = format.string(from: Double(amount) / 100_000_000.0 * rate.rate as NSNumber) else { return "" } + return string + } + + func string(isLtcSwapped: Bool) -> String { + return isLtcSwapped ? localCurrency : bits + } + + var ltcFormat: NumberFormatter { + let format = NumberFormatter() + format.isLenient = true + format.numberStyle = .currency + format.generatesDecimalNumbers = true + format.negativePrefix = "-" + format.currencyCode = "LTC" + + switch maxDigits { + case 2: // photons + format.currencySymbol = "m\(S.Symbols.lites)\(S.Symbols.narrowSpace)" + format.maximum = (C.maxMoney / C.satoshis) * 100_000 as NSNumber + case 5: // lites + format.currencySymbol = "\(S.Symbols.lites)\(S.Symbols.narrowSpace)" + format.maximum = (C.maxMoney / C.satoshis) * 1000 as NSNumber + case 8: // litecoin + format.currencySymbol = "\(S.Symbols.ltc)\(S.Symbols.narrowSpace)" + format.maximum = C.maxMoney / C.satoshis as NSNumber + default: + format.currencySymbol = "\(S.Symbols.lites)\(S.Symbols.narrowSpace)" + } + + format.maximumFractionDigits = maxDigits + format.minimumFractionDigits = 0 // iOS 8 bug, minimumFractionDigits now has to be set after currencySymbol + format.maximum = Decimal(C.maxMoney) / pow(10.0, maxDigits) as NSNumber + + return format + } + + var localFormat: NumberFormatter { + let format = NumberFormatter() + format.isLenient = true + format.numberStyle = .currency + format.generatesDecimalNumbers = true + format.negativePrefix = "-" + format.currencySymbol = rate.currencySymbol + return format + } +} + +struct DisplayAmount { + let amount: Satoshis + let state: ReduxState + let selectedRate: Rate? + let minimumFractionDigits: Int? + + var description: String { + return selectedRate != nil ? fiatDescription : litecoinDescription + } + + var combinedDescription: String { + return state.isLtcSwapped ? "\(fiatDescription) (\(litecoinDescription))" : "\(litecoinDescription) (\(fiatDescription))" + } + + private var fiatDescription: String { + guard let rate = selectedRate ?? state.currentRate else { return "" } + guard let string = localFormat.string(from: Double(amount.rawValue) / 100_000_000.0 * rate.rate as NSNumber) else { return "" } + return string + } + + private var litecoinDescription: String { + var decimal = Decimal(self.amount.rawValue) + var amount: Decimal = 0.0 + NSDecimalMultiplyByPowerOf10(&amount, &decimal, Int16(-state.maxDigits), .up) + let number = NSDecimalNumber(decimal: amount) + guard let string = ltcFormat.string(from: number) else { return "" } + return string + } + + var localFormat: NumberFormatter { + let format = NumberFormatter() + format.isLenient = true + format.numberStyle = .currency + format.generatesDecimalNumbers = true + format.negativePrefix = "-" + if let rate = selectedRate { + format.currencySymbol = rate.currencySymbol + } else if let rate = state.currentRate { + format.currencySymbol = rate.currencySymbol + } + if let minimumFractionDigits = minimumFractionDigits { + format.minimumFractionDigits = minimumFractionDigits + } + return format + } + + var ltcFormat: NumberFormatter { + let format = NumberFormatter() + format.isLenient = true + format.numberStyle = .currency + format.generatesDecimalNumbers = true + format.negativePrefix = "-" + format.currencyCode = "LTC" + + switch state.maxDigits { + case 2: + format.currencySymbol = "m\(S.Symbols.lites)\(S.Symbols.narrowSpace)" + format.maximum = (C.maxMoney / C.satoshis) * 100_000 as NSNumber + case 5: + format.currencySymbol = "\(S.Symbols.lites)\(S.Symbols.narrowSpace)" + format.maximum = (C.maxMoney / C.satoshis) * 1000 as NSNumber + case 8: + format.currencySymbol = "\(S.Symbols.ltc)\(S.Symbols.narrowSpace)" + format.maximum = C.maxMoney / C.satoshis as NSNumber + default: + format.currencySymbol = "\(S.Symbols.lites)\(S.Symbols.narrowSpace)" + } + + format.maximumFractionDigits = state.maxDigits + if let minimumFractionDigits = minimumFractionDigits { + format.minimumFractionDigits = minimumFractionDigits + } + format.maximum = Decimal(C.maxMoney) / pow(10.0, state.maxDigits) as NSNumber + + return format + } +} diff --git a/litewallet/src/ViewModels/Transaction.swift b/litewallet/src/ViewModels/Transaction.swift new file mode 100644 index 000000000..9e02031ea --- /dev/null +++ b/litewallet/src/ViewModels/Transaction.swift @@ -0,0 +1,395 @@ +import BRCore +import UIKit + +// Ideally this would be a struct, but it needs to be a class to allow +// for lazy variables + +struct TransactionStatusTuple { + var percentageString: String + var units: Int +} + +class Transaction { + // MARK: - Public + + init?(_ tx: BRTxRef, walletManager: WalletManager, kvStore: BRReplicatedKVStore?, rate: Rate?) { + guard let wallet = walletManager.wallet else { return nil } + guard let peerManager = walletManager.peerManager else { return nil } + + self.tx = tx + self.wallet = wallet + self.kvStore = kvStore + + let fee = wallet.feeForTx(tx) ?? 0 + self.fee = fee + + let amountReceived = wallet.amountReceivedFromTx(tx) + let amountSent = wallet.amountSentByTx(tx) + + if amountSent > 0, (amountReceived + fee) == amountSent { + direction = .moved + satoshis = amountSent + } else if amountSent > 0 { + direction = .sent + satoshis = amountSent - amountReceived - fee + } else { + direction = .received + satoshis = amountReceived + } + timestamp = Int(tx.pointee.timestamp) + + isValid = wallet.transactionIsValid(tx) + let transactionBlockHeight = tx.pointee.blockHeight + self.blockHeight = tx.pointee.blockHeight == UInt32(INT32_MAX) ? S.TransactionDetails.notConfirmedBlockHeightLabel.localize() : "\(tx.pointee.blockHeight)" + + let blockHeight = peerManager.lastBlockHeight + confirms = transactionBlockHeight > blockHeight ? 0 : Int(blockHeight - transactionBlockHeight) + 1 + status = makeStatus(tx, wallet: wallet, peerManager: peerManager, confirms: confirms, direction: direction) + + hash = tx.pointee.txHash.description + metaDataKey = tx.pointee.txHash.txKey + + if let rate = rate, confirms < 6, direction == .received { + attemptCreateMetaData(tx: tx, rate: rate) + } + } + + func amountDescription(isLtcSwapped: Bool, rate: Rate, maxDigits: Int) -> String { + let amount = Amount(amount: satoshis, rate: rate, maxDigits: maxDigits) + return isLtcSwapped ? amount.localCurrency : amount.bits + } + + func descriptionString(isLtcSwapped: Bool, rate: Rate, maxDigits: Int) -> NSAttributedString { + let amount = Amount(amount: satoshis, rate: rate, maxDigits: maxDigits).string(isLtcSwapped: isLtcSwapped) + let format = direction.amountDescriptionFormat + let string = String(format: format, amount) + return string.attributedStringForTags + } + + var detailsAddressText: String { + let address = toAddress?.largeCondensed + return String(format: direction.addressTextFormat, address ?? S.TransactionDetails.account.localize()) + } + + func amountDetails(isLtcSwapped: Bool, rate: Rate, rates: [Rate], maxDigits: Int) -> String { + let feeAmount = Amount(amount: fee, rate: rate, maxDigits: maxDigits) + let feeString = direction == .sent ? String(format: S.Transaction.fee.localize(), "\(feeAmount.string(isLtcSwapped: isLtcSwapped))") : "" + let amountString = "\(direction.sign)\(Amount(amount: satoshis, rate: rate, maxDigits: maxDigits).string(isLtcSwapped: isLtcSwapped)) \(feeString)" + var startingString = String(format: S.Transaction.starting.localize(), "\(Amount(amount: startingBalance, rate: rate, maxDigits: maxDigits).string(isLtcSwapped: isLtcSwapped))") + var endingString = String(format: String(format: S.Transaction.ending.localize(), "\(Amount(amount: balanceAfter, rate: rate, maxDigits: maxDigits).string(isLtcSwapped: isLtcSwapped))")) + + if startingBalance > C.maxMoney { + startingString = "" + endingString = "" + } + + var exchangeRateInfo = "" + if let metaData = metaData, let currentRate = rates.filter({ $0.code.lowercased() == metaData.exchangeRateCurrency.lowercased() }).first + { + let difference = (currentRate.rate - metaData.exchangeRate) / metaData.exchangeRate * 100.0 + let prefix = difference > 0.0 ? "+" : "-" + let firstLine = direction == .sent ? S.Transaction.exchangeOnDaySent : S.Transaction.exchangeOnDayReceived + let nf = NumberFormatter() + nf.currencySymbol = currentRate.currencySymbol + nf.numberStyle = .currency + if let rateString = nf.string(from: metaData.exchangeRate as NSNumber) { + let secondLine = "\(rateString)/LTC \(prefix)\(String(format: "%.2f", difference))%" + exchangeRateInfo = "\(firstLine)\n\(secondLine)" + } + } + + return "\(amountString)\n\(startingString)\n\(endingString)\n\(exchangeRateInfo)" + } + + func amountDetailsAmountString(isLtcSwapped: Bool, rate: Rate, rates _: [Rate], maxDigits: Int) -> String + { + let feeAmount = Amount(amount: fee, rate: rate, maxDigits: maxDigits) + let feeString = direction == .sent ? String(format: S.Transaction.fee.localize(), "\(feeAmount.string(isLtcSwapped: isLtcSwapped))") : "" + return "\(direction.sign)\(Amount(amount: satoshis, rate: rate, maxDigits: maxDigits).string(isLtcSwapped: isLtcSwapped)) \(feeString)" + } + + func amountDetailsStartingBalanceString(isLtcSwapped: Bool, rate: Rate, rates _: [Rate], maxDigits: Int) -> String + { + return String(format: S.Transaction.starting.localize(), "\(Amount(amount: startingBalance, rate: rate, maxDigits: maxDigits).string(isLtcSwapped: isLtcSwapped))") + } + + func amountDetailsEndingBalanceString(isLtcSwapped: Bool, rate: Rate, rates _: [Rate], maxDigits: Int) -> String + { + return String(format: String(format: S.Transaction.ending.localize(), "\(Amount(amount: balanceAfter, rate: rate, maxDigits: maxDigits).string(isLtcSwapped: isLtcSwapped))")) + } + + func amountExchangeString(isLtcSwapped _: Bool, rate _: Rate, rates: [Rate], maxDigits _: Int) -> String + { + var exchangeRateInfo = "" + if let metaData = metaData, let currentRate = rates.filter({ $0.code.lowercased() == metaData.exchangeRateCurrency.lowercased() }).first + { + let difference = (currentRate.rate - metaData.exchangeRate) / metaData.exchangeRate * 100.0 + let prefix = difference > 0.0 ? "+" : "-" + let nf = NumberFormatter() + nf.currencySymbol = currentRate.currencySymbol + nf.numberStyle = .currency + if let rateString = nf.string(from: metaData.exchangeRate as NSNumber) { + // TODO: Decide the usefulness of the rate percentage or a better way to describe it + let secondLine = "\(rateString)/LTC \(prefix)\(String(format: "%.2f", difference))%" + exchangeRateInfo = "\(secondLine)" + } + } + return exchangeRateInfo + } + + let direction: TransactionDirection + let status: String + let timestamp: Int + let fee: UInt64 + let hash: String + let isValid: Bool + let blockHeight: String + private let confirms: Int + private let metaDataKey: String + + // MARK: - Private + + private let tx: BRTxRef + private let wallet: BRWallet + private let satoshis: UInt64 + private var kvStore: BRReplicatedKVStore? + + lazy var toAddress: String? = { + switch self.direction { + case .sent: + + guard let output = self + .tx.outputs.filter({ output in + !self.wallet.containsAddress(output.updatedSwiftAddress) + }) + .first + + else { + LWAnalytics.logEventWithParameters(itemName: ._20200112_ERR) + return nil + } + + return output.updatedSwiftAddress + + case .received: + guard let output = self.tx.outputs.filter({ output in + self.wallet.containsAddress(output.updatedSwiftAddress) + + }).first + else { + LWAnalytics.logEventWithParameters(itemName: ._20200112_ERR) + return nil + } + return output.updatedSwiftAddress + + case .moved: + guard let output = self.tx.outputs.filter({ output in + self.wallet.containsAddress(output.updatedSwiftAddress) + }).first else { return nil } + return output.updatedSwiftAddress + } + }() + + var exchangeRate: Double? { + return metaData?.exchangeRate + } + + var comment: String? { + return metaData?.comment + } + + var hasKvStore: Bool { + return kvStore != nil + } + + var _metaData: TxMetaData? + var metaData: TxMetaData? { + if _metaData != nil { + return _metaData + } else { + guard let kvStore = kvStore else { return nil } + if let data = TxMetaData(txKey: metaDataKey, store: kvStore) { + _metaData = data + return _metaData + } else { + return nil + } + } + } + + private var balanceAfter: UInt64 { + return wallet.balanceAfterTx(tx) + } + + private lazy var startingBalance: UInt64 = { + switch self.direction { + case .received: + return + self.balanceAfter.subtractingReportingOverflow(self.satoshis).0.subtractingReportingOverflow(self.fee).0 + case .sent: + return self.balanceAfter.addingReportingOverflow(self.satoshis).0.addingReportingOverflow(self.fee).0 + case .moved: + return self.balanceAfter.addingReportingOverflow(self.fee).0 + } + }() + + var timeSince: (String, Bool) { + if let cached = timeSinceCache { + return cached + } + + let result: (String, Bool) + guard timestamp > 0 + else { + result = (S.Transaction.justNow.localize(), false) + timeSinceCache = result + return result + } + let then = Date(timeIntervalSince1970: TimeInterval(timestamp)) + let now = Date() + + if !now.hasEqualYear(then) { + let df = DateFormatter() + df.setLocalizedDateFormatFromTemplate("dd/MM/yy") + result = (df.string(from: then), false) + timeSinceCache = result + return result + } + + if !now.hasEqualMonth(then) { + let df = DateFormatter() + df.setLocalizedDateFormatFromTemplate("MMM dd") + result = (df.string(from: then), false) + timeSinceCache = result + return result + } + + let difference = Int(Date().timeIntervalSince1970) - timestamp + let secondsInMinute = 60 + let secondsInHour = 3600 + let secondsInDay = 86400 + let secondsInWeek = secondsInDay * 7 + if difference < secondsInMinute { + result = (String(format: S.TimeSince.seconds.localize(), "\(difference)"), true) + } else if difference < secondsInHour { + result = (String(format: S.TimeSince.minutes.localize(), "\(difference / secondsInMinute)"), true) + } else if difference < secondsInDay { + result = (String(format: S.TimeSince.hours.localize(), "\(difference / secondsInHour)"), false) + } else if difference < secondsInWeek { + result = (String(format: S.TimeSince.days.localize(), "\(difference / secondsInDay)"), false) + } else { + let df = DateFormatter() + df.setLocalizedDateFormatFromTemplate("MMM dd") + result = (df.string(from: then), false) + } + if result.1 == false { + timeSinceCache = result + } + return result + } + + private var timeSinceCache: (String, Bool)? + + var longTimestamp: String { + guard timestamp > 0 else { return wallet.transactionIsValid(tx) ? S.Transaction.justNow.localize() : "" } + let date = Date(timeIntervalSince1970: Double(timestamp)) + return Transaction.longDateFormatter.string(from: date) + } + + var shortTimestamp: String { + guard timestamp > 0 else { return wallet.transactionIsValid(tx) ? S.Transaction.justNow.localize() : "" } + let date = Date(timeIntervalSince1970: Double(timestamp)) + return Transaction.shortDateFormatter.string(from: date) + } + + var rawTransaction: BRTransaction { + return tx.pointee + } + + var isPending: Bool { + return confirms < 6 + } + + static let longDateFormatter: DateFormatter = { + let df = DateFormatter() + df.setLocalizedDateFormatFromTemplate("MMMM d, yyy h:mm a") + return df + }() + + static let shortDateFormatter: DateFormatter = { + let df = DateFormatter() + df.setLocalizedDateFormatFromTemplate("MMM d, h:mm a") + return df + }() + + private func attemptCreateMetaData(tx: BRTxRef, rate: Rate) { + guard metaData == nil else { return } + let newData = TxMetaData(transaction: tx.pointee, + exchangeRate: rate.rate, + exchangeRateCurrency: rate.code, + feeRate: 0.0, + deviceId: UserDefaults.standard.deviceID) + do { + _ = try kvStore?.set(newData) + } catch { + print("could not update metadata: \(error)") + } + } + + var shouldDisplayAvailableToSpend: Bool { + return confirms > 1 && confirms < 6 && direction == .received + } +} + +private extension String { + var smallCondensed: String { + let start = String(self[.. String +{ + let tx = txRef.pointee + guard wallet.transactionIsValid(txRef) + else { + return S.Transaction.invalid.localize() + } + + if confirms < 6 { + var percentageString = "" + if confirms == 0 { + let relayCount = peerManager.relayCount(tx.txHash) + if relayCount == 0 { + percentageString = "0%" + } else if relayCount == 1 { + percentageString = "20%" + } else if relayCount > 1 { + percentageString = "40%" + } + } else if confirms == 1 { + percentageString = "60%" + } else if confirms == 2 { + percentageString = "80%" + } else if confirms > 2 { + percentageString = "100%" + } + let format = direction == .sent ? S.Transaction.sendingStatus.localize() : S.Transaction.receivedStatus.localize() + return String(format: format, percentageString) + } else { + return S.Transaction.complete.localize() + } +} + +extension Transaction: Equatable {} + +func == (lhs: Transaction, rhs: Transaction) -> Bool { + return lhs.hash == rhs.hash && lhs.status == rhs.status && lhs.comment == rhs.comment && lhs.hasKvStore == rhs.hasKvStore +} diff --git a/litewallet/src/ViewModels/TransactionDirection.swift b/litewallet/src/ViewModels/TransactionDirection.swift new file mode 100644 index 000000000..9499e35f0 --- /dev/null +++ b/litewallet/src/ViewModels/TransactionDirection.swift @@ -0,0 +1,62 @@ +import Foundation + +enum TransactionDirection: String { + case sent = "Sent" + case received = "Received" + case moved = "Moved" + + var amountFormat: String { + switch self { + case .sent: + return S.TransactionDetails.sent.localize() + case .received: + return S.TransactionDetails.received.localize() + case .moved: + return S.TransactionDetails.moved.localize() + } + } + + var sign: String { + switch self { + case .sent: + return "-" + case .received: + return "" + case .moved: + return "" + } + } + + var addressHeader: String { + switch self { + case .sent: + return S.TransactionDirection.to.localize() + case .received: + return S.TransactionDirection.received.localize() + case .moved: + return S.TransactionDirection.to.localize() + } + } + + var amountDescriptionFormat: String { + switch self { + case .sent: + return S.TransactionDetails.sentAmountDescription.localize() + case .received: + return S.TransactionDetails.receivedAmountDescription.localize() + case .moved: + return S.TransactionDetails.movedAmountDescription.localize() + } + } + + var addressTextFormat: String { + switch self { + case .sent: + return S.TransactionDetails.to.localize() + case .received: + return S.TransactionDetails.from.localize() + case .moved: + return S.TransactionDetails.to.localize() + } + } +} diff --git a/litewallet/src/Views/AboutCell.swift b/litewallet/src/Views/AboutCell.swift new file mode 100644 index 000000000..c263a7d97 --- /dev/null +++ b/litewallet/src/Views/AboutCell.swift @@ -0,0 +1,52 @@ +import UIKit + +class AboutCell: UIView { + let button: UIButton + + init(text: String) { + button = UIButton.icon(image: #imageLiteral(resourceName: "OpenBrowser"), accessibilityLabel: text) + label.text = text + super.init(frame: .zero) + + if #available(iOS 11.0, *) { + guard let textColor = UIColor(named: "labelTextColor") + else { + NSLog("ERROR: Main color") + return + } + label.textColor = textColor + } + setup() + } + + private var label = UILabel(font: .customBody(size: 16.0), color: .darkText) + private let separator = UIView(color: .secondaryShadow) + + private func setup() { + addSubview(label) + addSubview(button) + addSubview(separator) + + label.constrain([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: C.padding[2]), + label.topAnchor.constraint(equalTo: topAnchor, constant: C.padding[2]), + label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -C.padding[2]), + ]) + button.constrain([ + button.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -C.padding[2]), + button.centerYAnchor.constraint(equalTo: label.centerYAnchor), + ]) + separator.constrain([ + separator.leadingAnchor.constraint(equalTo: label.leadingAnchor), + separator.trailingAnchor.constraint(equalTo: button.trailingAnchor), + separator.bottomAnchor.constraint(equalTo: bottomAnchor), + separator.heightAnchor.constraint(equalToConstant: 1.0), + ]) + button.tintColor = C.defaultTintColor + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/AlertView.swift b/litewallet/src/Views/AlertView.swift new file mode 100644 index 000000000..58a72e7db --- /dev/null +++ b/litewallet/src/Views/AlertView.swift @@ -0,0 +1,168 @@ +import UIKit + +enum AlertType { + case pinSet(callback: () -> Void) + case paperKeySet(callback: () -> Void) + case sendSuccess + case resolvedSuccess + case addressesCopied + case sweepSuccess(callback: () -> Void) + + // Failure(s) + case failedResolution + + var header: String { + switch self { + case .pinSet: + return S.SecurityAlerts.pinSet.localize() + case .paperKeySet: + return S.SecurityAlerts.paperKeySet.localize() + case .sendSuccess: + return S.SecurityAlerts.sendSuccess.localize() + case .resolvedSuccess: + return S.SecurityAlerts.resolvedSuccess.localize() + case .addressesCopied: + return S.SecurityAlerts.copiedAddressesHeader.localize() + case .sweepSuccess: + return S.Import.success.localize() + + // Failure(s) + case .failedResolution: + return S.SecurityAlerts.sendFailure.localize() + } + } + + var subheader: String { + switch self { + case .pinSet: + return "" + case .paperKeySet: + return S.SecurityAlerts.paperKeySetSubheader.localize() + case .sendSuccess: + return S.SecurityAlerts.sendSuccessSubheader.localize() + case .resolvedSuccess: + return S.SecurityAlerts.resolvedSuccessSubheader.localize() + case .addressesCopied: + return S.SecurityAlerts.copiedAddressesSubheader.localize() + case .sweepSuccess: + return S.Import.successBody.localize() + + // Failure(s) + case .failedResolution: + return S.SecurityAlerts.resolvedSuccessSubheader.localize() + } + } + + var icon: UIView { + return CheckView() + } +} + +extension AlertType: Equatable {} + +func == (lhs: AlertType, rhs: AlertType) -> Bool { + switch (lhs, rhs) { + case (.pinSet(_), .pinSet(_)): + return true + case (.paperKeySet(_), .paperKeySet(_)): + return true + case (.sendSuccess, .sendSuccess): + return true + case (.resolvedSuccess, .resolvedSuccess): + return true + case (.addressesCopied, .addressesCopied): + return true + case (.sweepSuccess(_), .sweepSuccess(_)): + return true + + // Failure(s) + case (.failedResolution, .failedResolution): + return true + + default: + return false + } +} + +class AlertView: UIView, SolidColorDrawable { + private let type: AlertType + private let header = UILabel() + private let subheader = UILabel() + private let separator = UIView() + private let icon: UIView + private let iconSize: CGFloat = 96.0 + private let separatorYOffset: CGFloat = 48.0 + + init(type: AlertType) { + self.type = type + icon = type.icon + super.init(frame: .zero) + layer.cornerRadius = 6.0 + layer.masksToBounds = true + setupSubviews() + } + + func animate() { + guard let animatableIcon = icon as? AnimatableIcon else { return } + animatableIcon.animate() + } + + private func setupSubviews() { + addSubview(header) + addSubview(subheader) + addSubview(icon) + addSubview(separator) + + setData() + addConstraints() + } + + private func setData() { + header.text = type.header + header.textAlignment = .center + header.font = UIFont.barlowBold(size: 18.0) + header.textColor = .white + + icon.backgroundColor = .clear + separator.backgroundColor = .transparentWhite + + subheader.text = type.subheader + subheader.textAlignment = .center + subheader.font = UIFont.barlowSemiBold(size: 16.0) + subheader.textColor = .white + } + + private func addConstraints() { + // NB - In this alert view, constraints shouldn't be pinned to the bottom + // of the view because the bottom actually extends off the bottom of the screen a bit. + // It extends so that it still covers up the underlying view when it bounces on screen. + + header.constrainTopCorners(sidePadding: C.padding[2], topPadding: C.padding[2]) + separator.constrain([ + separator.constraint(.height, constant: 1.0), + separator.constraint(.width, toView: self, constant: 0.0), + separator.constraint(.top, toView: self, constant: separatorYOffset), + separator.constraint(.leading, toView: self, constant: nil), + ]) + icon.constrain([ + icon.constraint(.centerX, toView: self, constant: nil), + icon.constraint(.centerY, toView: self, constant: nil), + icon.constraint(.width, constant: iconSize), + icon.constraint(.height, constant: iconSize), + ]) + subheader.constrain([ + subheader.constraint(.leading, toView: self, constant: C.padding[2]), + subheader.constraint(.trailing, toView: self, constant: -C.padding[2]), + subheader.constraint(toBottom: icon, constant: C.padding[3]), + ]) + } + + override func draw(_ rect: CGRect) { + drawColor(color: .liteWalletBlue, rect) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/AnimatedIcons/AnimatableIcon.swift b/litewallet/src/Views/AnimatedIcons/AnimatableIcon.swift new file mode 100644 index 000000000..b9209e05e --- /dev/null +++ b/litewallet/src/Views/AnimatedIcons/AnimatableIcon.swift @@ -0,0 +1,5 @@ +import UIKit + +protocol AnimatableIcon { + func animate() +} diff --git a/litewallet/src/Views/AnimatedIcons/CheckView.swift b/litewallet/src/Views/AnimatedIcons/CheckView.swift new file mode 100644 index 000000000..c93f7f215 --- /dev/null +++ b/litewallet/src/Views/AnimatedIcons/CheckView.swift @@ -0,0 +1,68 @@ +import UIKit + +class CheckView: UIView, AnimatableIcon { + public func animate() { + let check = UIBezierPath() + check.move(to: CGPoint(x: 32.5, y: 47.0)) + check.addLine(to: CGPoint(x: 43.0, y: 57.0)) + check.addLine(to: CGPoint(x: 63, y: 37.4)) + + let shape = CAShapeLayer() + shape.path = check.cgPath + shape.lineWidth = 9.0 + shape.strokeColor = UIColor.white.cgColor + shape.fillColor = UIColor.clear.cgColor + shape.strokeStart = 0.0 + shape.strokeEnd = 0.0 + shape.lineCap = .round + shape.lineJoin = .round + layer.addSublayer(shape) + + let animation = CABasicAnimation(keyPath: "strokeEnd") + animation.toValue = 1.0 + animation.isRemovedOnCompletion = false + animation.fillMode = CAMediaTimingFillMode.forwards + animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.default) + animation.duration = 0.3 + + shape.add(animation, forKey: nil) + } + + override func draw(_: CGRect) { + let checkcircle = UIBezierPath() + checkcircle.move(to: CGPoint(x: 47.76, y: -0)) + checkcircle.addCurve(to: CGPoint(x: 0, y: 47.76), controlPoint1: CGPoint(x: 21.38, y: -0), controlPoint2: CGPoint(x: 0, y: 21.38)) + checkcircle.addCurve(to: CGPoint(x: 47.76, y: 95.52), controlPoint1: CGPoint(x: 0, y: 74.13), controlPoint2: CGPoint(x: 21.38, y: 95.52)) + checkcircle.addCurve(to: CGPoint(x: 95.52, y: 47.76), controlPoint1: CGPoint(x: 74.14, y: 95.52), controlPoint2: CGPoint(x: 95.52, y: 74.13)) + checkcircle.addCurve(to: CGPoint(x: 47.76, y: -0), controlPoint1: CGPoint(x: 95.52, y: 21.38), controlPoint2: CGPoint(x: 74.14, y: -0)) + checkcircle.addLine(to: CGPoint(x: 47.76, y: -0)) + checkcircle.close() + checkcircle.move(to: CGPoint(x: 47.99, y: 85.97)) + checkcircle.addCurve(to: CGPoint(x: 9.79, y: 47.76), controlPoint1: CGPoint(x: 26.89, y: 85.97), controlPoint2: CGPoint(x: 9.79, y: 68.86)) + checkcircle.addCurve(to: CGPoint(x: 47.99, y: 9.55), controlPoint1: CGPoint(x: 9.79, y: 26.66), controlPoint2: CGPoint(x: 26.89, y: 9.55)) + checkcircle.addCurve(to: CGPoint(x: 86.2, y: 47.76), controlPoint1: CGPoint(x: 69.1, y: 9.55), controlPoint2: CGPoint(x: 86.2, y: 26.66)) + checkcircle.addCurve(to: CGPoint(x: 47.99, y: 85.97), controlPoint1: CGPoint(x: 86.2, y: 68.86), controlPoint2: CGPoint(x: 69.1, y: 85.97)) + checkcircle.close() + + UIColor.white.setFill() + checkcircle.fill() + + // This is the non-animated check left here for now as a reference + // let check = UIBezierPath() + // check.move(to: CGPoint(x: 30.06, y: 51.34)) + // check.addCurve(to: CGPoint(x: 30.06, y: 44.75), controlPoint1: CGPoint(x: 28.19, y: 49.52), controlPoint2: CGPoint(x: 28.19, y: 46.57)) + // check.addCurve(to: CGPoint(x: 36.9, y: 44.69), controlPoint1: CGPoint(x: 32, y: 42.87), controlPoint2: CGPoint(x: 35.03, y: 42.87)) + // check.addLine(to: CGPoint(x: 42.67, y: 50.3)) + // check.addLine(to: CGPoint(x: 58.62, y: 34.79)) + // check.addCurve(to: CGPoint(x: 65.39, y: 34.8), controlPoint1: CGPoint(x: 60.49, y: 32.98), controlPoint2: CGPoint(x: 63.53, y: 32.98)) + // check.addCurve(to: CGPoint(x: 65.46, y: 41.45), controlPoint1: CGPoint(x: 67.33, y: 36.68), controlPoint2: CGPoint(x: 67.33, y: 39.63)) + // check.addLine(to: CGPoint(x: 45.33, y: 61.02)) + // check.addCurve(to: CGPoint(x: 40.01, y: 61.02), controlPoint1: CGPoint(x: 43.86, y: 62.44), controlPoint2: CGPoint(x: 41.48, y: 62.44)) + // check.addLine(to: CGPoint(x: 30.06, y: 51.34)) + // check.close() + // check.move(to: CGPoint(x: 30.06, y: 51.34)) +// + // UIColor.green.setFill() + // check.fill() + } +} diff --git a/litewallet/src/Views/BlinkingView.swift b/litewallet/src/Views/BlinkingView.swift new file mode 100644 index 000000000..2352411ce --- /dev/null +++ b/litewallet/src/Views/BlinkingView.swift @@ -0,0 +1,28 @@ +import UIKit + +class BlinkingView: UIView { + init(blinkColor: UIColor) { + self.blinkColor = blinkColor + super.init(frame: .zero) + } + + func startBlinking() { + timer = Timer.scheduledTimer(timeInterval: 0.53, target: self, selector: #selector(update), userInfo: nil, repeats: true) + } + + func stopBlinking() { + timer?.invalidate() + } + + @objc private func update() { + backgroundColor = backgroundColor == .clear ? blinkColor : .clear + } + + private let blinkColor: UIColor + private var timer: Timer? + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/CameraGuideView.swift b/litewallet/src/Views/CameraGuideView.swift new file mode 100644 index 000000000..7692bf1e4 --- /dev/null +++ b/litewallet/src/Views/CameraGuideView.swift @@ -0,0 +1,72 @@ +import UIKit + +private let guideSize: CGFloat = 64.0 +private let lineWidth: CGFloat = 8.0 + +enum CameraGuideState { + case normal, positive, negative + + var color: UIColor { + switch self { + case .normal: return .darkLine + case .negative: return .litewalletOrange + case .positive: return .cameraGuidePositive + } + } +} + +class CameraGuideView: UIView { + var state: CameraGuideState = .normal { + didSet { + setNeedsDisplay() + } + } + + init() { + super.init(frame: .zero) + backgroundColor = .clear + } + + override func draw(_ rect: CGRect) { + guard let context = UIGraphicsGetCurrentContext() else { return } + + // top left + context.addLineThrough([ + (0 + lineWidth / 2.0, guideSize), + (0 + lineWidth / 2.0, 0 + lineWidth / 2.0), + (guideSize, 0 + lineWidth / 2.0), + ]) + + // top right + context.addLineThrough([ + (rect.maxX - guideSize, 0 + lineWidth / 2.0), + (rect.maxX - lineWidth / 2.0, 0 + lineWidth / 2.0), + (rect.maxX - lineWidth / 2.0, guideSize), + ]) + + // bottom right + context.addLineThrough([ + (rect.maxX - lineWidth / 2.0, rect.maxY - guideSize), + (rect.maxY - lineWidth / 2.0, rect.maxY - lineWidth / 2.0), + (rect.maxX - guideSize, rect.maxY - lineWidth / 2.0), + ]) + + // bottom left + context.addLineThrough([ + (lineWidth / 2.0, rect.maxY - guideSize), + (lineWidth / 2.0, rect.maxY - lineWidth / 2.0), + (guideSize, rect.maxY - lineWidth / 2.0), + ]) + + state.color.setStroke() + context.setLineCap(.round) + context.setLineJoin(.round) + context.setLineWidth(lineWidth) + context.strokePath() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/Circle.swift b/litewallet/src/Views/Circle.swift new file mode 100644 index 000000000..ac2218fa3 --- /dev/null +++ b/litewallet/src/Views/Circle.swift @@ -0,0 +1,25 @@ +import UIKit + +class Circle: UIView { + private let color: UIColor + + static let defaultSize: CGFloat = 64.0 + + init(color: UIColor) { + self.color = color + super.init(frame: .zero) + backgroundColor = .clear + } + + override func draw(_ rect: CGRect) { + guard let context = UIGraphicsGetCurrentContext() else { return } + context.addEllipse(in: rect) + context.setFillColor(color.cgColor) + context.fillPath() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/ConfirmPhrase.swift b/litewallet/src/Views/ConfirmPhrase.swift new file mode 100644 index 000000000..320a39673 --- /dev/null +++ b/litewallet/src/Views/ConfirmPhrase.swift @@ -0,0 +1,105 @@ +import UIKit + +private let circleRadius: CGFloat = 12.0 + +class ConfirmPhrase: UIView { + let word: String + let textField = UITextField() + var callback: (() -> Void)? + var doneCallback: (() -> Void)? + var isEditingCallback: (() -> Void)? + + init(text: String, word: String) { + self.word = word + super.init(frame: CGRect()) + translatesAutoresizingMaskIntoConstraints = false + label.text = text + setupSubviews() + } + + private let label = UILabel() + private let separator = UIView() + private let circle = DrawableCircle() + + private func setupSubviews() { + label.font = UIFont.customBody(size: 14.0) + label.textColor = UIColor(white: 170.0 / 255.0, alpha: 1.0) + separator.backgroundColor = .separatorGray + + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.font = UIFont.customBody(size: 16.0) + textField.textColor = .darkText + textField.delegate = self + + addSubview(label) + addSubview(textField) + addSubview(separator) + addSubview(circle) + + label.constrain([ + label.constraint(.leading, toView: self, constant: C.padding[1]), + label.constraint(.top, toView: self, constant: C.padding[1]), + ]) + textField.constrain([ + textField.constraint(.leading, toView: label, constant: nil), + textField.constraint(toBottom: label, constant: C.padding[1] / 2.0), + textField.constraint(.width, toView: self, constant: -C.padding[1] * 2), + ]) + + separator.constrainBottomCorners(sidePadding: 0.0, bottomPadding: 0.0) + separator.constrain([ + // This contraint to the bottom of the textField is pretty crucial. Without it, + // this view will have an intrinsicHeight of 0 + separator.constraint(toBottom: textField, constant: C.padding[1]), + separator.constraint(.height, constant: 1.0), + ]) + circle.constrain([ + circle.centerYAnchor.constraint(equalTo: textField.centerYAnchor), + circle.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -C.padding[2]), + circle.heightAnchor.constraint(equalToConstant: circleRadius * 2.0), + circle.widthAnchor.constraint(equalToConstant: circleRadius * 2.0), + ]) + + textField.addTarget(self, action: #selector(textFieldChanged), for: .editingChanged) + } + + func validate() { + if textField.text != word { + textField.textColor = .litewalletOrange + } + } + + @objc private func textFieldChanged() { + textField.textColor = .darkText + guard textField.markedTextRange == nil else { return } + if textField.text == word { + circle.show() + textField.isEnabled = false + } + callback?() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension ConfirmPhrase: UITextFieldDelegate { + func textFieldDidEndEditing(_: UITextField) { + validate() + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + textField.textColor = .darkText + isEditingCallback?() + } + + func textFieldShouldReturn(_: UITextField) -> Bool { + if E.isIPhone4 { + doneCallback?() + } + return true + } +} diff --git a/litewallet/src/Views/DefaultCurrencyViewController.swift b/litewallet/src/Views/DefaultCurrencyViewController.swift new file mode 100644 index 000000000..35624ca83 --- /dev/null +++ b/litewallet/src/Views/DefaultCurrencyViewController.swift @@ -0,0 +1,171 @@ +import UIKit + +class DefaultCurrencyViewController: UITableViewController, Subscriber { + init(walletManager: WalletManager, store: Store) { + self.walletManager = walletManager + self.store = store + rates = store.state.rates.filter { $0.code != C.btcCurrencyCode } + super.init(style: .plain) + } + + private let walletManager: WalletManager + private let store: Store + private let cellIdentifier = "CellIdentifier" + private var rates: [Rate] = [] { + didSet { + tableView.reloadData() + setExchangeRateLabel() + } + } + + private var defaultCurrencyCode: String? { + didSet { + // Grab index paths of new and old rows when the currency changes + let paths: [IndexPath] = rates.enumerated().filter { $0.1.code == defaultCurrencyCode || $0.1.code == oldValue }.map { IndexPath(row: $0.0, section: 0) } + tableView.beginUpdates() + tableView.reloadRows(at: paths, with: .automatic) + tableView.endUpdates() + + setExchangeRateLabel() + } + } + + private let bitcoinLabel = UILabel(font: .customBold(size: 14.0), color: .grayTextTint) + private let bitcoinSwitch = UISegmentedControl(items: ["photons (\(S.Symbols.photons))", "lites (\(S.Symbols.lites))", "LTC (\(S.Symbols.ltc))"]) + private let rateLabel = UILabel(font: .customBody(size: 16.0), color: .darkText) + private var header: UIView? + + deinit { + store.unsubscribe(self) + } + + override func viewDidLoad() { + tableView.register(SeparatorCell.self, forCellReuseIdentifier: cellIdentifier) + store.subscribe(self, selector: { $0.defaultCurrencyCode != $1.defaultCurrencyCode }, callback: { + self.defaultCurrencyCode = $0.defaultCurrencyCode + + }) + store.subscribe(self, selector: { $0.maxDigits != $1.maxDigits }, callback: { _ in + self.setExchangeRateLabel() + }) + + tableView.sectionHeaderHeight = UITableView.automaticDimension + tableView.estimatedSectionHeaderHeight = 140.0 + tableView.backgroundColor = .whiteTint + tableView.separatorStyle = .none + + let titleLabel = UILabel(font: .customBold(size: 17.0), color: .darkText) + titleLabel.text = S.Settings.currency.localize() + titleLabel.sizeToFit() + navigationItem.titleView = titleLabel + + let faqButton = UIButton.buildFaqButton(store: store, articleId: ArticleIds.nothing) + faqButton.tintColor = .darkText + navigationItem.rightBarButtonItems = [UIBarButtonItem.negativePadding, UIBarButtonItem(customView: faqButton)] + } + + private func setExchangeRateLabel() { + if let currentRate = rates.filter({ $0.code == defaultCurrencyCode }).first { + let amount = Amount(amount: C.satoshis, rate: currentRate, maxDigits: store.state.maxDigits) + let bitsAmount = Amount(amount: C.satoshis, rate: currentRate, maxDigits: store.state.maxDigits) + rateLabel.textColor = .darkText + rateLabel.text = "\(bitsAmount.bits) = \(amount.string(forLocal: currentRate.locale))" + } + } + + override func numberOfSections(in _: UITableView) -> Int { + return 1 + } + + override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { + return rates.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell + { + let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) + let rate = rates[indexPath.row] + cell.textLabel?.text = "\(rate.code) (\(rate.currencySymbol))" + + if rate.code == defaultCurrencyCode { + let check = UIImageView(image: #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate)) + check.tintColor = C.defaultTintColor + cell.accessoryView = check + } else { + cell.accessoryView = nil + } + + return cell + } + + override func tableView(_: UITableView, viewForHeaderInSection _: Int) -> UIView? { + if let header = self.header { return header } + + let header = UIView(color: .whiteTint) + let rateLabelTitle = UILabel(font: .customBold(size: 14.0), color: .grayTextTint) + + header.addSubview(rateLabelTitle) + header.addSubview(rateLabel) + header.addSubview(bitcoinLabel) + header.addSubview(bitcoinSwitch) + + rateLabelTitle.constrain([ + rateLabelTitle.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: C.padding[2]), + rateLabelTitle.topAnchor.constraint(equalTo: header.topAnchor, constant: C.padding[1]), + ]) + rateLabel.constrain([ + rateLabel.leadingAnchor.constraint(equalTo: rateLabelTitle.leadingAnchor), + rateLabel.topAnchor.constraint(equalTo: rateLabelTitle.bottomAnchor), + ]) + + bitcoinLabel.constrain([ + bitcoinLabel.leadingAnchor.constraint(equalTo: rateLabelTitle.leadingAnchor), + bitcoinLabel.topAnchor.constraint(equalTo: rateLabel.bottomAnchor, constant: C.padding[2]), + ]) + bitcoinSwitch.constrain([ + bitcoinSwitch.leadingAnchor.constraint(equalTo: bitcoinLabel.leadingAnchor), + bitcoinSwitch.topAnchor.constraint(equalTo: bitcoinLabel.bottomAnchor, constant: C.padding[1]), + bitcoinSwitch.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -C.padding[2]), + bitcoinSwitch.widthAnchor.constraint(equalTo: header.widthAnchor, constant: -C.padding[4]), + ]) + + let settingSegment = store.state.maxDigits + switch settingSegment { + case 2: bitcoinSwitch.selectedSegmentIndex = 0 + case 5: bitcoinSwitch.selectedSegmentIndex = 1 + case 8: bitcoinSwitch.selectedSegmentIndex = 2 + default: bitcoinSwitch.selectedSegmentIndex = 2 + } + + bitcoinSwitch.valueChanged = strongify(self) { myself in + let newIndex = myself.bitcoinSwitch.selectedSegmentIndex + + switch newIndex { + case 0: // photons + myself.store.perform(action: MaxDigits.set(2)) + case 1: // lites + myself.store.perform(action: MaxDigits.set(5)) + case 2: // LTC + myself.store.perform(action: MaxDigits.set(8)) + default: // LTC + myself.store.perform(action: MaxDigits.set(8)) + } + } + + bitcoinLabel.text = S.DefaultCurrency.bitcoinLabel.localize() + rateLabelTitle.text = S.DefaultCurrency.rateLabel.localize() + + self.header = header + return header + } + + override func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { + let rate = rates[indexPath.row] + store.perform(action: DefaultCurrency.setDefault(rate.code)) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/DrawableCircle.swift b/litewallet/src/Views/DrawableCircle.swift new file mode 100644 index 000000000..9500a8d34 --- /dev/null +++ b/litewallet/src/Views/DrawableCircle.swift @@ -0,0 +1,58 @@ +import UIKit + +class DrawableCircle: UIView { + private let circleLayer = CAShapeLayer() + private let checkLayer = CAShapeLayer() + private var hasPerformedLayout = false + private let originalCheckSize: CGFloat = 96.0 + private let animationDuration: TimeInterval = 0.4 + + override func layoutSubviews() { + guard !hasPerformedLayout else { hasPerformedLayout = true; return } + clipsToBounds = false + backgroundColor = .clear + + let path = UIBezierPath(arcCenter: bounds.center, radius: bounds.width / 2.0, startAngle: .pi / 2.0, endAngle: (.pi / 2.0) - .pi * 2.0, clockwise: false) + circleLayer.path = path.cgPath + circleLayer.fillColor = UIColor.clear.cgColor + circleLayer.strokeColor = C.defaultTintColor.cgColor + circleLayer.lineWidth = 1.0 + circleLayer.opacity = 0 + layer.addSublayer(circleLayer) + + let check = UIBezierPath() + let scaleFactor = (bounds.width) / originalCheckSize + check.move(to: CGPoint(x: 32.5 * scaleFactor, y: 47.0 * scaleFactor)) + check.addLine(to: CGPoint(x: 43.0 * scaleFactor, y: 57.0 * scaleFactor)) + check.addLine(to: CGPoint(x: 63 * scaleFactor, y: 37.4 * scaleFactor)) + + checkLayer.path = check.cgPath + checkLayer.lineWidth = 2.0 + checkLayer.strokeColor = UIColor.white.cgColor + checkLayer.strokeColor = C.defaultTintColor.cgColor + checkLayer.fillColor = UIColor.clear.cgColor + checkLayer.strokeEnd = 0.0 + checkLayer.lineCap = .round + checkLayer.lineJoin = .round + layer.addSublayer(checkLayer) + } + + func show() { + let circleAnimation = CABasicAnimation(keyPath: "opacity") + circleAnimation.duration = animationDuration + circleAnimation.timingFunction = CAMediaTimingFunction(name: .easeIn) + circleAnimation.fromValue = 0.0 + circleAnimation.toValue = 1.0 + circleLayer.opacity = 1.0 + circleLayer.add(circleAnimation, forKey: "drawCircle") + + let checkAnimation = CABasicAnimation(keyPath: "strokeEnd") + checkAnimation.fromValue = 0.0 + checkAnimation.toValue = 1.0 + checkAnimation.fillMode = .forwards + checkAnimation.timingFunction = CAMediaTimingFunction(name: .easeIn) + checkAnimation.duration = animationDuration + checkLayer.strokeEnd = 1.0 + checkLayer.add(checkAnimation, forKey: "drawCheck") + } +} diff --git a/litewallet/src/Views/EnterPhraseCell.swift b/litewallet/src/Views/EnterPhraseCell.swift new file mode 100644 index 000000000..ab7eae008 --- /dev/null +++ b/litewallet/src/Views/EnterPhraseCell.swift @@ -0,0 +1,170 @@ +import UIKit + +class EnterPhraseCell: UICollectionViewCell { + // MARK: - Public + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + var index: Int? { + didSet { + guard let index = index else { return } + label.text = "\(index + 1)" + } + } + + private(set) var text: String? + + var didTapPrevious: (() -> Void)? { + didSet { + previousField.tap = didTapPrevious + } + } + + var didTapNext: (() -> Void)? { + didSet { + nextField.tap = didTapNext + } + } + + var didTapDone: (() -> Void)? { + didSet { + done.tap = { + self.textField.resignFirstResponder() + self.didTapDone?() + } + } + } + + var didEnterSpace: (() -> Void)? + var isWordValid: ((String) -> Bool)? + + func disablePreviousButton() { + previousField.tintColor = .secondaryShadow + previousField.isEnabled = false + } + + func disableNextButton() { + nextField.tintColor = .secondaryShadow + nextField.isEnabled = false + } + + // MARK: - Private + + let textField = UITextField() + private let label = UILabel(font: .customBody(size: 13.0), color: .secondaryShadow) + private let nextField = UIButton.icon(image: #imageLiteral(resourceName: "RightArrow"), accessibilityLabel: S.RecoverWallet.rightArrow.localize()) + private let previousField = UIButton.icon(image: #imageLiteral(resourceName: "LeftArrow"), accessibilityLabel: S.RecoverWallet.leftArrow.localize()) + private let done = UIButton(type: .system) + fileprivate let separator = UIView(color: .secondaryShadow) + fileprivate var hasDisplayedInvalidState = false + + private func setup() { + contentView.addSubview(textField) + contentView.addSubview(separator) + contentView.addSubview(label) + + textField.constrain([ + textField.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + textField.topAnchor.constraint(equalTo: contentView.topAnchor, constant: C.padding[1]), + textField.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + separator.constrain([ + separator.leadingAnchor.constraint(equalTo: textField.leadingAnchor, constant: C.padding[1]), + separator.topAnchor.constraint(equalTo: textField.bottomAnchor), + separator.trailingAnchor.constraint(equalTo: textField.trailingAnchor, constant: -C.padding[1]), + separator.heightAnchor.constraint(equalToConstant: 1.0), + ]) + label.constrain([ + label.leadingAnchor.constraint(equalTo: separator.leadingAnchor), + label.topAnchor.constraint(equalTo: separator.bottomAnchor), + label.trailingAnchor.constraint(equalTo: separator.trailingAnchor), + ]) + setData() + } + + private func setData() { + textField.inputAccessoryView = accessoryView + textField.autocorrectionType = .no + textField.textAlignment = .center + textField.autocapitalizationType = .none + textField.delegate = self + textField.addTarget(self, action: #selector(EnterPhraseCell.textChanged(textField:)), for: .editingChanged) + + label.textAlignment = .center + previousField.tintColor = .secondaryGrayText + nextField.tintColor = .secondaryGrayText + done.setTitle(S.RecoverWallet.done.localize(), for: .normal) + } + + private var accessoryView: UIView { + let view = UIView(color: .secondaryButton) + view.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 44) + let topBorder = UIView(color: .secondaryShadow) + view.addSubview(topBorder) + view.addSubview(previousField) + view.addSubview(nextField) + view.addSubview(done) + + topBorder.constrainTopCorners(height: 1.0) + previousField.constrain([ + previousField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: C.padding[2]), + previousField.topAnchor.constraint(equalTo: view.topAnchor), + previousField.bottomAnchor.constraint(equalTo: view.bottomAnchor), + previousField.widthAnchor.constraint(equalToConstant: 44.0), + ]) + + nextField.constrain([ + nextField.leadingAnchor.constraint(equalTo: previousField.trailingAnchor), + nextField.topAnchor.constraint(equalTo: view.topAnchor), + nextField.bottomAnchor.constraint(equalTo: view.bottomAnchor), + nextField.widthAnchor.constraint(equalToConstant: 44.0), + ]) + + done.constrain([ + done.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -C.padding[2]), + done.topAnchor.constraint(equalTo: view.topAnchor), + done.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + return view + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension EnterPhraseCell: UITextFieldDelegate { + func textFieldDidEndEditing(_ textField: UITextField) { + setColors(textField: textField) + } + + @objc func textChanged(textField: UITextField) { + if let text = textField.text { + if text.last == " " { + textField.text = text.replacingOccurrences(of: " ", with: "") + didEnterSpace?() + } + } + if hasDisplayedInvalidState { + setColors(textField: textField) + } + } + + private func setColors(textField: UITextField) { + guard let isWordValid = isWordValid else { return } + guard let word = textField.text else { return } + if isWordValid(word) || word == "" { + textField.textColor = .darkText + separator.backgroundColor = .secondaryShadow + } else { + textField.textColor = .litewalletOrange + separator.backgroundColor = .litewalletOrange + hasDisplayedInvalidState = true + } + } +} diff --git a/litewallet/src/Views/GradientCircle.swift b/litewallet/src/Views/GradientCircle.swift new file mode 100644 index 000000000..4a26177fa --- /dev/null +++ b/litewallet/src/Views/GradientCircle.swift @@ -0,0 +1,26 @@ +import UIKit + +class GradientCircle: UIView, GradientDrawable { + static let defaultSize: CGFloat = 64.0 + + init() { + super.init(frame: CGRect()) + backgroundColor = .clear + } + + override func draw(_ rect: CGRect) { + drawGradient(rect) + maskToCircle(rect) + } + + private func maskToCircle(_ rect: CGRect) { + let maskLayer = CAShapeLayer() + maskLayer.path = UIBezierPath(ovalIn: rect).cgPath + layer.mask = maskLayer + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/GradientSwitch.swift b/litewallet/src/Views/GradientSwitch.swift new file mode 100644 index 000000000..6028b6f5b --- /dev/null +++ b/litewallet/src/Views/GradientSwitch.swift @@ -0,0 +1,34 @@ +import UIKit + +class GradientSwitch: UISwitch { + init() { + super.init(frame: .zero) + setup() + } + + private let background: GradientView = { + let view = GradientView() + view.clipsToBounds = true + view.layer.cornerRadius = 16.0 + view.alpha = 0.0 + return view + }() + + private func setup() { + onTintColor = .clear + insertSubview(background, at: 0) + background.constrain(toSuperviewEdges: nil) + addTarget(self, action: #selector(toggleBackground), for: .valueChanged) + } + + @objc private func toggleBackground() { + UIView.animate(withDuration: 0.1, animations: { + self.background.alpha = self.isOn ? 1.0 : 0.0 + }) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/GradientView.swift b/litewallet/src/Views/GradientView.swift new file mode 100644 index 000000000..3b673430c --- /dev/null +++ b/litewallet/src/Views/GradientView.swift @@ -0,0 +1,49 @@ +import UIKit + +protocol GradientDrawable { + func drawGradient(_ rect: CGRect) +} + +extension UIView { + func drawGradient(_ rect: CGRect) { + guard !E.isIPhone4, !E.isIPhone5 + else { + addFallbackImageBackground() + return + } + let colorSpace = CGColorSpaceCreateDeviceRGB() + let colors = [UIColor.gradientStart.cgColor, UIColor.gradientEnd.cgColor] as CFArray + let locations: [CGFloat] = [0.2, 0.9] // eyeball attempt! REDO + guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: locations) else { return } + guard let context = UIGraphicsGetCurrentContext() else { return } + context.drawLinearGradient(gradient, start: .zero, end: CGPoint(x: rect.width, y: 0.0), options: []) + } + + private func addFallbackImageBackground() { + let image = UIImageView(image: #imageLiteral(resourceName: "HeaderGradient")) + image.contentMode = .scaleToFill + addSubview(image) + image.constrain(toSuperviewEdges: nil) + sendSubviewToBack(image) + } +} + +class GradientView: UIView { + override func draw(_ rect: CGRect) { + drawGradient(rect) + } +} + +protocol SolidColorDrawable { + func drawColor(color: UIColor, _ rect: CGRect) +} + +extension UIView { + func drawColor(color _: UIColor = .liteWalletBlue, _: CGRect) { + let image = UIImageView(image: #imageLiteral(resourceName: "colorImageBlue")) + image.contentMode = .scaleToFill + addSubview(image) + image.constrain(toSuperviewEdges: nil) + sendSubviewToBack(image) + } +} diff --git a/litewallet/src/Views/InAppAlert.swift b/litewallet/src/Views/InAppAlert.swift new file mode 100644 index 000000000..1a6b113e4 --- /dev/null +++ b/litewallet/src/Views/InAppAlert.swift @@ -0,0 +1,67 @@ +import UIKit + +class InAppAlert: UIView { + init(message: String, image: UIImage) { + super.init(frame: .zero) + setup() + self.image.image = image + self.message.text = message + } + + static let height: CGFloat = 136.0 + var bottomConstraint: NSLayoutConstraint? + var hide: (() -> Void)? + + private let close = UIButton.close + private let message = UILabel.wrapping(font: .customBody(size: 16.0), color: .whiteTint) + private let image = UIImageView() + + override func draw(_ rect: CGRect) { + let colorSpace = CGColorSpaceCreateDeviceRGB() + let colors = [UIColor.blueGradientStart.cgColor, UIColor.blueGradientEnd.cgColor] as CFArray + let locations: [CGFloat] = [0.0, 1.0] + guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: locations) else { return } + guard let context = UIGraphicsGetCurrentContext() else { return } + context.drawLinearGradient(gradient, start: CGPoint(x: rect.midX, y: 0.0), end: CGPoint(x: rect.midX, y: rect.height), options: []) + } + + private func setup() { + addSubview(close) + addSubview(image) + addSubview(message) + close.constrain([ + close.topAnchor.constraint(equalTo: topAnchor, constant: C.padding[2]), + close.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -C.padding[2]), + close.widthAnchor.constraint(equalToConstant: 44.0), + close.heightAnchor.constraint(equalToConstant: 44.0), + ]) + image.constrain([ + image.centerXAnchor.constraint(equalTo: centerXAnchor), + image.topAnchor.constraint(equalTo: topAnchor, constant: C.padding[4]), + ]) + message.constrain([ + message.leadingAnchor.constraint(equalTo: leadingAnchor, constant: C.padding[2]), + message.topAnchor.constraint(equalTo: image.bottomAnchor, constant: C.padding[1]), + message.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -C.padding[2]), + ]) + close.tap = { [weak self] in + self?.dismiss() + } + close.tintColor = .whiteTint + message.textAlignment = .center + } + + func dismiss() { + UIView.animate(withDuration: C.animationDuration, animations: { + self.bottomConstraint?.constant = 0.0 + self.superview?.layoutIfNeeded() + }, completion: { _ in + self.removeFromSuperview() + }) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/InViewAlert.swift b/litewallet/src/Views/InViewAlert.swift new file mode 100644 index 000000000..bdbb85224 --- /dev/null +++ b/litewallet/src/Views/InViewAlert.swift @@ -0,0 +1,99 @@ +import UIKit + +enum InViewAlertType { + case primary + case secondary +} + +private let arrowHeight: CGFloat = 8.0 +private let arrowWidth: CGFloat = 16.0 + +class InViewAlert: UIView { + var heightConstraint: NSLayoutConstraint? + var isExpanded = false + var contentView: UIView? { + didSet { + guard let view = contentView else { return } + addSubview(view) + view.constrain(toSuperviewEdges: UIEdgeInsets(top: arrowHeight, left: 0, bottom: 0, right: 0)) + } + } + + var arrowXLocation: CGFloat? + static var arrowSize: CGSize { + return CGSize(width: arrowWidth, height: arrowHeight) + } + + var height: CGFloat { + switch type { + case .primary: + return 72.0 + case .secondary: + return 81.0 + } + } + + init(type: InViewAlertType) { + self.type = type + super.init(frame: .zero) + setupSubViews() + } + + func toggle() { + heightConstraint?.constant = isExpanded ? 0.0 : height + } + + private let type: InViewAlertType + + private func setupSubViews() { + contentMode = .redraw + backgroundColor = .clear + } + + override func draw(_ rect: CGRect) { + let background = UIBezierPath(rect: rect.offsetBy(dx: 0, dy: arrowHeight)) + fillColor.setFill() + background.fill() + + let context = UIGraphicsGetCurrentContext() + let center = arrowXLocation != nil ? arrowXLocation! : rect.width / 2.0 + + let triangle = CGMutablePath() + triangle.move(to: CGPoint(x: center - arrowWidth / 2.0 + 0.5, y: arrowHeight + 0.5)) + triangle.addLine(to: CGPoint(x: center + 0.5, y: 0.5)) + triangle.addLine(to: CGPoint(x: center + arrowWidth / 2.0 + 0.5, y: arrowHeight + 0.5)) + triangle.closeSubpath() + context?.setLineJoin(.miter) + context?.setFillColor(fillColor.cgColor) + context?.addPath(triangle) + context?.fillPath() + + // Add Gray border for secondary style + if type == .secondary { + let topBorder = CGMutablePath() + topBorder.move(to: CGPoint(x: 0, y: arrowHeight)) + topBorder.addLine(to: CGPoint(x: center - arrowWidth / 2.0 + 0.5, y: arrowHeight + 0.5)) + topBorder.addLine(to: CGPoint(x: center + 0.5, y: 0.5)) + topBorder.addLine(to: CGPoint(x: center + arrowWidth / 2.0 + 0.5, y: arrowHeight + 0.5)) + topBorder.addLine(to: CGPoint(x: rect.width + 0.5, y: arrowHeight + 0.5)) + context?.setLineWidth(1.0) + context?.setStrokeColor(UIColor.secondaryShadow.cgColor) + context?.addPath(topBorder) + context?.strokePath() + } + } + + private var fillColor: UIColor { + switch type { + case .primary: + return .primaryButton + case .secondary: + return .grayBackgroundTint + } + } + + @available(*, unavailable) + required init(coder _: NSCoder) { + fatalError("This class does not support NSCoding") + } +} diff --git a/litewallet/src/Views/LightWeightAlert.swift b/litewallet/src/Views/LightWeightAlert.swift new file mode 100644 index 000000000..debaa1218 --- /dev/null +++ b/litewallet/src/Views/LightWeightAlert.swift @@ -0,0 +1,30 @@ +import UIKit + +class LightWeightAlert: UIView { + init(message: String) { + super.init(frame: .zero) + label.text = message + setup() + } + + let effect = UIBlurEffect(style: .dark) + let background = UIVisualEffectView() + let container = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .dark))) + private let label = UILabel(font: .customBold(size: 16.0)) + + private func setup() { + addSubview(background) + background.constrain(toSuperviewEdges: nil) + background.contentView.addSubview(container) + container.contentView.addSubview(label) + container.constrain(toSuperviewEdges: nil) + label.constrain(toSuperviewEdges: UIEdgeInsets(top: C.padding[2], left: C.padding[2], bottom: -C.padding[2], right: -C.padding[2])) + layer.cornerRadius = 4.0 + layer.masksToBounds = true + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/LoadingProgressView.swift b/litewallet/src/Views/LoadingProgressView.swift new file mode 100644 index 000000000..15e206ec9 --- /dev/null +++ b/litewallet/src/Views/LoadingProgressView.swift @@ -0,0 +1,88 @@ +import UIKit + +private let progressHeight: CGFloat = 4.0 +private let progressWidth: CGFloat = 150.0 + +class LoadingProgressView: UIView, GradientDrawable { + var progress: Double = 0.0 { + didSet { + progressWidthConstraint?.constant = CGFloat(progress) * progressWidth + } + } + + init() { + super.init(frame: .zero) + } + + private var hasSetup = false + + private lazy var progressBackground: UIView = self.makeProgressView(backgroundColor: .transparentBlack) + private lazy var progressForeground: UIView = self.makeProgressView(backgroundColor: .white) + + private func makeProgressView(backgroundColor: UIColor) -> UIView { + let view = UIView() + view.layer.cornerRadius = progressHeight / 2.0 + view.layer.masksToBounds = true + view.backgroundColor = backgroundColor + return view + } + + private let label = UILabel(font: .customBold(size: 14.0)) + private let shadowView = UIView() + private var progressWidthConstraint: NSLayoutConstraint? + + private func setupView() { + label.textColor = .white + label.text = S.Account.loadingMessage + label.textAlignment = .center + + addSubview(label) + addSubview(progressBackground) + addSubview(shadowView) + + label.constrain([ + label.centerXAnchor.constraint(equalTo: centerXAnchor), + label.bottomAnchor.constraint(equalTo: progressBackground.topAnchor, constant: -4.0), + ]) + progressBackground.constrain([ + progressBackground.centerXAnchor.constraint(equalTo: centerXAnchor), + progressBackground.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -C.padding[1]), + progressBackground.heightAnchor.constraint(equalToConstant: progressHeight), + progressBackground.widthAnchor.constraint(equalToConstant: progressWidth), + ]) + progressBackground.addSubview(progressForeground) + progressWidthConstraint = progressForeground.widthAnchor.constraint(equalToConstant: 0.0) + progressForeground.constrain([ + progressWidthConstraint, + progressForeground.leadingAnchor.constraint(equalTo: progressBackground.leadingAnchor), + progressForeground.heightAnchor.constraint(equalTo: progressBackground.heightAnchor), + progressForeground.centerYAnchor.constraint(equalTo: progressBackground.centerYAnchor), + ]) + shadowView.backgroundColor = .transparentWhite + shadowView.constrainTopCorners(height: 0.5) + } + + override func layoutSubviews() { + if !hasSetup { + setupView() + hasSetup = true + } + addBottomCorners() + } + + private func addBottomCorners() { + let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: [.bottomLeft, .bottomRight], cornerRadii: CGSize(width: 6.0, height: 6.0)).cgPath + let maskLayer = CAShapeLayer() + maskLayer.path = path + layer.mask = maskLayer + } + + override func draw(_ rect: CGRect) { + drawGradient(rect) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/Views/LoginBackgroundTriangle.swift b/litewallet/src/Views/LoginBackgroundTriangle.swift similarity index 100% rename from litewallet/Views/LoginBackgroundTriangle.swift rename to litewallet/src/Views/LoginBackgroundTriangle.swift diff --git a/litewallet/src/Views/ModalHeaderView.swift b/litewallet/src/Views/ModalHeaderView.swift new file mode 100644 index 000000000..84e0ff2f4 --- /dev/null +++ b/litewallet/src/Views/ModalHeaderView.swift @@ -0,0 +1,80 @@ +import UIKit + +enum ModalHeaderViewStyle { + case light + case dark +} + +class ModalHeaderView: UIView { + // MARK: - Public + + var closeCallback: (() -> Void)? { + didSet { close.tap = closeCallback } + } + + init(title: String, style: ModalHeaderViewStyle, faqInfo _: (Store, String)? = nil, showCloseButton: Bool = true) + { + self.title.text = title + self.style = style + + self.showCloseButton = showCloseButton + super.init(frame: .zero) + setupSubviews() + setColors() + } + + let showCloseButton: Bool + + // MARK: - Private + + private let title = UILabel(font: .barlowSemiBold(size: 17.0)) + private let close = UIButton.close + private let border = UIView() + private let buttonSize: CGFloat = 44.0 + private let style: ModalHeaderViewStyle + + private func setupSubviews() { + addSubview(title) + addSubview(border) + if showCloseButton { + addSubview(close) + close.constrain([ + close.constraint(.leading, toView: self, constant: 0.0), + close.constraint(.centerY, toView: self, constant: 0.0), + close.constraint(.height, constant: buttonSize), + close.constraint(.width, constant: buttonSize), + ]) + } + + title.constrain([ + title.constraint(.centerX, toView: self, constant: 0.0), + title.constraint(.centerY, toView: self, constant: 0.0), + ]) + border.constrain([ + border.constraint(.height, constant: 1.0), + ]) + border.constrainBottomCorners(sidePadding: 0, bottomPadding: 0) + } + + private func setColors() { + if #available(iOS 11.0, *), + let textColor = UIColor(named: "inverseTextColor") + { + backgroundColor = textColor + } else { + backgroundColor = .white + } + switch style { + case .light: + title.textColor = .white + close.tintColor = .white + case .dark: + border.backgroundColor = .secondaryShadow + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/NonScrollingCollectionView.swift b/litewallet/src/Views/NonScrollingCollectionView.swift new file mode 100644 index 000000000..5f3fd5822 --- /dev/null +++ b/litewallet/src/Views/NonScrollingCollectionView.swift @@ -0,0 +1,10 @@ +import UIKit + +// This class disables all scrolling. This is desired +// when we don't want the scrollView to scroll to the active +// textField +class NonScrollingCollectionView: UICollectionView { + override func setContentOffset(_: CGPoint, animated _: Bool) { + // noop + } +} diff --git a/litewallet/src/Views/PhraseView.swift b/litewallet/src/Views/PhraseView.swift new file mode 100644 index 000000000..080771c7f --- /dev/null +++ b/litewallet/src/Views/PhraseView.swift @@ -0,0 +1,32 @@ +import UIKit + +class PhraseView: UIView { + private let phrase: String + private let label = UILabel() + + static let defaultSize = CGSize(width: 200, height: 88.0) + + var xConstraint: NSLayoutConstraint? + + init(phrase: String) { + self.phrase = phrase + super.init(frame: CGRect()) + setupSubviews() + } + + private func setupSubviews() { + addSubview(label) + label.constrainToCenter() + label.textColor = .white + label.adjustsFontSizeToFitWidth = true + label.text = phrase + label.font = UIFont.customBold(size: 30.0) + backgroundColor = .liteWalletBlue + layer.cornerRadius = 10.0 + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/PinPadCells/PinPadCells.swift b/litewallet/src/Views/PinPadCells/PinPadCells.swift new file mode 100644 index 000000000..1db5470c8 --- /dev/null +++ b/litewallet/src/Views/PinPadCells/PinPadCells.swift @@ -0,0 +1 @@ +import UIKit diff --git a/litewallet/src/Views/PinView.swift b/litewallet/src/Views/PinView.swift new file mode 100644 index 000000000..39b266ed1 --- /dev/null +++ b/litewallet/src/Views/PinView.swift @@ -0,0 +1,111 @@ +import UIKit + +enum PinViewStyle { + case create + case login +} + +class PinView: UIView { + // MARK: - Public + + var itemSize: CGFloat { + switch style { + case .create: + return 24.0 + case .login: + return 16.0 + } + } + + var width: CGFloat { + return (itemSize + C.padding[1]) * CGFloat(length) + } + + let shakeDuration: CFTimeInterval = 0.6 + fileprivate var shakeCompletion: (() -> Void)? + + init(style: PinViewStyle, length: Int) { + self.style = style + self.length = length + switch style { + case .create: + unFilled = (0 ... (length - 1)).map { _ in Circle(color: .transparentWhite) } + case .login: + unFilled = (0 ... (length - 1)).map { _ in Circle(color: .transparentWhite) } + } + filled = (0 ... (length - 1)).map { _ in Circle(color: .white) } + super.init(frame: CGRect()) + setupSubviews() + } + + func fill(_ number: Int) { + filled.enumerated().forEach { index, circle in + circle.isHidden = index > number - 1 + } + } + + func shake(completion: (() -> Void)? = nil) { + shakeCompletion = completion + let translation = CAKeyframeAnimation(keyPath: "transform.translation.x") + translation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear) + translation.values = [-5, 5, -5, 5, -3, 3, -2, 2, 0] + + let rotation = CAKeyframeAnimation(keyPath: "transform.rotation.y") + rotation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear) + + rotation.values = [-5, 5, -5, 5, -3, 3, -2, 2, 0].map { + self.toRadian(value: $0) + } + let shakeGroup = CAAnimationGroup() + shakeGroup.animations = [translation, rotation] + shakeGroup.duration = shakeDuration + shakeGroup.delegate = self + layer.add(shakeGroup, forKey: "shakeIt") + } + + // MARK: - Private + + private let unFilled: [Circle] + private var filled: [Circle] + private let style: PinViewStyle + private let length: Int + + private func toRadian(value: Int) -> CGFloat { + return CGFloat(Double(value) / 180.0 * .pi) + } + + private func setupSubviews() { + addCircleContraints(unFilled) + addCircleContraints(filled) + filled.forEach { $0.isHidden = true } + } + + private func addCircleContraints(_ circles: [Circle]) { + circles.enumerated().forEach { index, circle in + addSubview(circle) + let leadingConstraint: NSLayoutConstraint? + if index == 0 { + leadingConstraint = circle.constraint(.leading, toView: self, constant: 0.0) + } else { + leadingConstraint = NSLayoutConstraint(item: circle, attribute: .leading, relatedBy: .equal, toItem: circles[index - 1], attribute: .trailing, multiplier: 1.0, constant: 8.0) + } + circle.constrain([ + circle.constraint(.width, constant: itemSize), + circle.constraint(.height, constant: itemSize), + circle.constraint(.centerY, toView: self, constant: nil), + leadingConstraint, + ]) + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension PinView: CAAnimationDelegate { + func animationDidStop(_: CAAnimation, finished _: Bool) { + shakeCompletion?() + } +} diff --git a/litewallet/src/Views/RadialGradientView.swift b/litewallet/src/Views/RadialGradientView.swift new file mode 100644 index 000000000..ab295ca19 --- /dev/null +++ b/litewallet/src/Views/RadialGradientView.swift @@ -0,0 +1,33 @@ +import UIKit + +class RadialGradientView: UIView { + // MARK: - Public + + init(backgroundColor: UIColor, offset: CGFloat = 0.0) { + self.offset = offset + super.init(frame: .zero) + self.backgroundColor = backgroundColor + } + + // MARK: - Private + + private let offset: CGFloat + + override func draw(_ rect: CGRect) { + let colorSpace = CGColorSpaceCreateDeviceRGB() + let startColor = UIColor.transparentWhite.cgColor + let endColor = UIColor(white: 1.0, alpha: 0.0).cgColor + let colors = [startColor, endColor] as CFArray + let locations: [CGFloat] = [0.0, 1.0] + guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: locations) else { return } + guard let context = UIGraphicsGetCurrentContext() else { return } + let center = CGPoint(x: rect.midX, y: rect.midY + offset) + let endRadius = rect.height + context.drawRadialGradient(gradient, startCenter: center, startRadius: 0.0, endCenter: center, endRadius: endRadius, options: []) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/SearchHeaderView.swift b/litewallet/src/Views/SearchHeaderView.swift new file mode 100644 index 000000000..f0eecd854 --- /dev/null +++ b/litewallet/src/Views/SearchHeaderView.swift @@ -0,0 +1,263 @@ +import UIKit + +enum SearchFilterType { + case sent + case received + case pending + case complete + case text(String) + + var description: String { + switch self { + case .sent: + return S.Search.sent.localize() + case .received: + return S.Search.received.localize() + case .pending: + return S.Search.pending.localize() + case .complete: + return S.Search.complete.localize() + case .text: + return "" + } + } + + var filter: TransactionFilter { + switch self { + case .sent: + return { $0.direction == .sent } + case .received: + return { $0.direction == .received } + case .pending: + return { $0.isPending } + case .complete: + return { !$0.isPending } + case let .text(text): + return { transaction in + let loweredText = text.lowercased() + if transaction.hash.lowercased().contains(loweredText) { + return true + } + if let address = transaction.toAddress { + if address.lowercased().contains(loweredText) { + return true + } + } + if let metaData = transaction.metaData { + if metaData.comment.lowercased().contains(loweredText) { + return true + } + } + return false + } + } + } +} + +extension SearchFilterType: Equatable {} + +func == (lhs: SearchFilterType, rhs: SearchFilterType) -> Bool { + switch (lhs, rhs) { + case (.sent, .sent): + return true + case (.received, .received): + return true + case (.pending, .pending): + return true + case (.complete, .complete): + return true + case (.text(_), .text(_)): + return true + default: + return false + } +} + +typealias TransactionFilter = (Transaction) -> Bool + +class SearchHeaderView: UIView { + init() { + super.init(frame: .zero) + } + + var didCancel: (() -> Void)? + var didChangeFilters: (([TransactionFilter]) -> Void)? + var hasSetup = false + + func triggerUpdate() { + didChangeFilters?(filters.map { $0.filter }) + } + + private let searchBar = UISearchBar() + private let sent = ShadowButton(title: S.Search.sent.localize(), type: .search) + private let received = ShadowButton(title: S.Search.received.localize(), type: .search) + private let pending = ShadowButton(title: S.Search.pending.localize(), type: .search) + private let complete = ShadowButton(title: S.Search.complete.localize(), type: .search) + private let cancel = UIButton(type: .system) + fileprivate var filters: [SearchFilterType] = [] { + didSet { + didChangeFilters?(filters.map { $0.filter }) + } + } + + private let sentFilter: TransactionFilter = { $0.direction == .sent } + private let receivedFilter: TransactionFilter = { $0.direction == .received } + + override func layoutSubviews() { + guard !hasSetup else { return } + setup() + hasSetup = true + } + + private func setup() { + addSubviews() + addFilterButtons() + addConstraints() + setData() + } + + private func addSubviews() { + addSubview(searchBar) + addSubview(cancel) + } + + private func addConstraints() { + cancel.setTitle(S.Button.cancel.localize(), for: .normal) + let titleSize = NSString(string: cancel.titleLabel!.text!).size(withAttributes: [NSAttributedString.Key.font: cancel.titleLabel!.font]) + cancel.constrain([ + cancel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -C.padding[2]), + cancel.centerYAnchor.constraint(equalTo: searchBar.centerYAnchor), + cancel.widthAnchor.constraint(equalToConstant: titleSize.width + C.padding[4]), + ]) + searchBar.constrain([ + searchBar.leadingAnchor.constraint(equalTo: leadingAnchor, constant: C.padding[1]), + searchBar.topAnchor.constraint(equalTo: topAnchor, constant: E.isIPhoneX ? C.padding[5] : C.padding[2]), + searchBar.trailingAnchor.constraint(equalTo: cancel.leadingAnchor, constant: -C.padding[1]), + ]) + } + + private func setData() { + backgroundColor = .whiteTint + searchBar.backgroundImage = UIImage() + searchBar.delegate = self + + cancel.tap = { [weak self] in + self?.didChangeFilters?([]) + self?.searchBar.resignFirstResponder() + self?.didCancel?() + } + cancel.tintColor = UIColor(red: 52.0 / 255.0, green: 52.0 / 255.0, blue: 157.0 / 255.0, alpha: 1.0) + cancel.titleLabel?.font = UIFont.customBody(size: 14.0) + + sent.isToggleable = true + received.isToggleable = true + pending.isToggleable = true + complete.isToggleable = true + + sent.tap = { [weak self] in + guard let myself = self else { return } + if myself.toggleFilterType(.sent) { + if myself.received.isSelected { + myself.received.isSelected = false + myself.toggleFilterType(.received) + } + } + } + + received.tap = { [weak self] in + guard let myself = self else { return } + if myself.toggleFilterType(.received) { + if myself.sent.isSelected { + myself.sent.isSelected = false + myself.toggleFilterType(.sent) + } + } + } + + pending.tap = { [weak self] in + guard let myself = self else { return } + if myself.toggleFilterType(.pending) { + if myself.complete.isSelected { + myself.complete.isSelected = false + myself.toggleFilterType(.complete) + } + } + } + + complete.tap = { [weak self] in + guard let myself = self else { return } + if myself.toggleFilterType(.complete) { + if myself.pending.isSelected { + myself.pending.isSelected = false + myself.toggleFilterType(.pending) + } + } + } + } + + @discardableResult private func toggleFilterType(_ filterType: SearchFilterType) -> Bool { + if let index = filters.index(of: filterType) { + filters.remove(at: index) + return false + } else { + filters.append(filterType) + return true + } + } + + private func addFilterButtons() { + if #available(iOS 9, *) { + let stackView = UIStackView() + addSubview(stackView) + stackView.distribution = .fillProportionally + stackView.spacing = C.padding[1] + stackView.constrain([ + stackView.leadingAnchor.constraint(equalTo: searchBar.leadingAnchor), + stackView.topAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: C.padding[1]), + stackView.trailingAnchor.constraint(equalTo: cancel.trailingAnchor), + ]) + stackView.addArrangedSubview(sent) + stackView.addArrangedSubview(received) + stackView.addArrangedSubview(pending) + stackView.addArrangedSubview(complete) + } else { + addSubview(sent) + addSubview(received) + addSubview(pending) + addSubview(complete) + sent.constrain([ + sent.leadingAnchor.constraint(equalTo: searchBar.leadingAnchor), + sent.topAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: C.padding[1]), + ]) + received.constrain([ + received.leadingAnchor.constraint(equalTo: sent.trailingAnchor, constant: C.padding[1]), + received.topAnchor.constraint(equalTo: sent.topAnchor), + ]) + pending.constrain([ + pending.leadingAnchor.constraint(equalTo: received.trailingAnchor, constant: C.padding[1]), + pending.topAnchor.constraint(equalTo: received.topAnchor), + ]) + complete.constrain([ + complete.leadingAnchor.constraint(equalTo: pending.trailingAnchor, constant: C.padding[1]), + complete.topAnchor.constraint(equalTo: sent.topAnchor), + ]) + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension SearchHeaderView: UISearchBarDelegate { + func searchBar(_: UISearchBar, textDidChange searchText: String) { + let filter: SearchFilterType = .text(searchText) + if let index = filters.index(of: filter) { + filters.remove(at: index) + } + if searchText != "" { + filters.append(filter) + } + } +} diff --git a/litewallet/src/Views/SecurityCenterCell.swift b/litewallet/src/Views/SecurityCenterCell.swift new file mode 100644 index 000000000..7cdf17298 --- /dev/null +++ b/litewallet/src/Views/SecurityCenterCell.swift @@ -0,0 +1,92 @@ +import UIKit + +private let buttonSize: CGFloat = 16.0 + +class SecurityCenterCell: UIControl { + // MARK: - Public + + var isCheckHighlighted: Bool = false { + didSet { + check.tintColor = isCheckHighlighted ? .primaryButton : .grayTextTint + } + } + + init(title: String, descriptionText: String) { + super.init(frame: .zero) + titleLabel.text = title + + if #available(iOS 11.0, *) { + guard let headerTextColor = UIColor(named: "headerTextColor"), + let labelTextColor = UIColor(named: "labelTextColor"), + let backgroundColor = UIColor(named: "lfBackgroundColor") + else { + NSLog("ERROR: Custom colors not set") + return + } + check.tintColor = headerTextColor + titleLabel.textColor = labelTextColor + descriptionLabel.textColor = labelTextColor + self.backgroundColor = backgroundColor + } + + descriptionLabel.text = descriptionText + setup() + } + + // MARK: - Private + + private func setup() { + addSubview(titleLabel) + addSubview(descriptionLabel) + addSubview(separator) + addSubview(check) + check.constrain([ + check.leadingAnchor.constraint(equalTo: leadingAnchor, constant: C.padding[2]), + check.topAnchor.constraint(equalTo: topAnchor, constant: C.padding[2]), + check.widthAnchor.constraint(equalToConstant: buttonSize), + check.heightAnchor.constraint(equalToConstant: buttonSize), + ]) + titleLabel.constrain([ + titleLabel.leadingAnchor.constraint(equalTo: check.trailingAnchor, constant: C.padding[1]), + titleLabel.topAnchor.constraint(equalTo: check.topAnchor), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -C.padding[2]), + ]) + descriptionLabel.constrain([ + descriptionLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + descriptionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor), + descriptionLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + ]) + separator.constrain([ + separator.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: C.padding[3]), + separator.leadingAnchor.constraint(equalTo: check.leadingAnchor), + separator.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + separator.heightAnchor.constraint(equalToConstant: 1.0), + separator.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + descriptionLabel.numberOfLines = 0 + descriptionLabel.lineBreakMode = .byWordWrapping + check.setImage(#imageLiteral(resourceName: "CircleCheck"), for: .normal) + isCheckHighlighted = false + } + + override var isHighlighted: Bool { + didSet { + if isHighlighted { + backgroundColor = .litecoinSilver + } else { + backgroundColor = .white + } + } + } + + private var titleLabel = UILabel(font: .customBold(size: 13.0)) + private var descriptionLabel = UILabel(font: .customBody(size: 13.0)) + private var separator = UIView(color: .secondaryShadow) + private var check = UIButton(type: .system) + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/SendAmountCell.swift b/litewallet/src/Views/SendAmountCell.swift new file mode 100644 index 000000000..413fd107c --- /dev/null +++ b/litewallet/src/Views/SendAmountCell.swift @@ -0,0 +1,105 @@ +import UIKit + +class SendAmountCell: SendCell { + init(placeholder: String) { + super.init() + let attributes: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key.foregroundColor: UIColor.grayTextTint, + NSAttributedString.Key.font: placeholderFont, + ] + textField.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: attributes) + textField.delegate = self + textField.textColor = .darkText + textField.inputView = UIView() + setupViews() + } + + var textFieldDidBeginEditing: (() -> Void)? + var textFieldDidReturn: ((UITextField) -> Void)? + var textFieldDidChange: ((String) -> Void)? + var content: String? { + didSet { + textField.text = content + textField.sendActions(for: .editingChanged) + guard let count = content?.count else { return } + textField.font = count > 0 ? textFieldFont : placeholderFont + } + } + + func setLabel(text: String, color: UIColor) { + label.text = text + label.textColor = color + } + + func setAmountLabel(text: String) { + textField.isHidden = text.utf8.count > 0 // Textfield should be hidden if amount label has text + cursor.isHidden = !textField.isHidden + amountLabel.text = text + } + + private let placeholderFont = UIFont.customBody(size: 16.0) + private let textFieldFont = UIFont.customBody(size: 26.0) + let textField = UITextField() + let label = UILabel(font: .customBody(size: 14.0), color: .grayTextTint) + let amountLabel = UILabel(font: .customBody(size: 26.0), color: .darkText) + private let cursor = BlinkingView(blinkColor: C.defaultTintColor) + + private func setupViews() { + addSubview(textField) + addSubview(label) + addSubview(amountLabel) + addSubview(cursor) + + textField.constrain([ + textField.constraint(.leading, toView: self, constant: C.padding[2]), + textField.centerYAnchor.constraint(equalTo: accessoryView.centerYAnchor), + textField.heightAnchor.constraint(greaterThanOrEqualToConstant: 30.0), + textField.constraint(toLeading: accessoryView, constant: 0.0), + ]) + label.constrain([ + label.leadingAnchor.constraint(equalTo: textField.leadingAnchor), + label.topAnchor.constraint(equalTo: amountLabel.bottomAnchor, constant: C.padding[2]), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -C.padding[2]), + label.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + amountLabel.constrain([ + amountLabel.leadingAnchor.constraint(equalTo: textField.leadingAnchor), + amountLabel.topAnchor.constraint(equalTo: textField.topAnchor), + amountLabel.bottomAnchor.constraint(equalTo: textField.bottomAnchor), + ]) + cursor.constrain([ + cursor.leadingAnchor.constraint(equalTo: amountLabel.trailingAnchor, constant: 2.0), + cursor.heightAnchor.constraint(equalTo: amountLabel.heightAnchor, constant: -4.0), + cursor.centerYAnchor.constraint(equalTo: amountLabel.centerYAnchor), + cursor.widthAnchor.constraint(equalToConstant: 2.0), + ]) + + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + + textField.addTarget(self, action: #selector(SendAmountCell.editingChanged(textField:)), for: .editingChanged) + cursor.startBlinking() + cursor.isHidden = true + } + + @objc private func editingChanged(textField: UITextField) { + guard let text = textField.text else { return } + textFieldDidChange?(text) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension SendAmountCell: UITextFieldDelegate { + func textFieldDidBeginEditing(_: UITextField) { + textFieldDidBeginEditing?() + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textFieldDidReturn?(textField) + return true + } +} diff --git a/litewallet/src/Views/SendViewCells/AddressCell.swift b/litewallet/src/Views/SendViewCells/AddressCell.swift new file mode 100644 index 000000000..7e0a42942 --- /dev/null +++ b/litewallet/src/Views/SendViewCells/AddressCell.swift @@ -0,0 +1,110 @@ +import UIKit + +class AddressCell: UIView { + init() { + super.init(frame: .zero) + setupViews() + } + + var address: String? { + return textField.text + } + + var didBeginEditing: (() -> Void)? + var didEndEditing: (() -> Void)? + var didReceivePaymentRequest: ((PaymentRequest) -> Void)? + + let textField = UITextField() + let paste = ShadowButton(title: S.Send.pasteLabel.localize(), type: .tertiary) + let scan = ShadowButton(title: S.Send.scanLabel.localize(), type: .tertiary) + private let dividerView = UIView(color: .secondaryShadow) + + private func setupViews() { + if #available(iOS 11.0, *) { + guard let textColor = UIColor(named: "labelTextColor") + else { + NSLog("ERROR: Main color") + return + } + textField.textColor = textColor + } else { + textField.textColor = .darkText + } + addSubviews() + addConstraints() + setInitialData() + } + + private func addSubviews() { + addSubview(textField) + addSubview(dividerView) + addSubview(paste) + addSubview(scan) + } + + private func addConstraints() { + textField.constrain([ + textField.constraint(.leading, toView: self, constant: 11.0), + textField.constraint(.centerY, toView: self), + textField.trailingAnchor.constraint(equalTo: paste.leadingAnchor, constant: -C.padding[1]), + ]) + scan.constrain([ + scan.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -C.padding[2]), + scan.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + paste.constrain([ + paste.centerYAnchor.constraint(equalTo: centerYAnchor), + paste.trailingAnchor.constraint(equalTo: scan.leadingAnchor, constant: -C.padding[0.625]), + ]) + dividerView.constrain([ + dividerView.leadingAnchor.constraint(equalTo: leadingAnchor), + dividerView.bottomAnchor.constraint(equalTo: bottomAnchor), + dividerView.trailingAnchor.constraint(equalTo: trailingAnchor), + dividerView.heightAnchor.constraint(equalToConstant: 1.0), + ]) + } + + private func setInitialData() { + textField.font = .customBody(size: 15.0) + textField.adjustsFontSizeToFitWidth = true + textField.minimumFontSize = 10.0 + textField.placeholder = S.Send.enterLTCAddressLabel.localize() + textField.returnKeyType = .done + textField.delegate = self + textField.clearButtonMode = .whileEditing + } + + @objc private func didTap() { + textField.becomeFirstResponder() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension AddressCell: UITextFieldDelegate { + func textFieldDidBeginEditing(_: UITextField) { + didBeginEditing?() + } + + func textFieldDidEndEditing(_: UITextField) { + didEndEditing?() + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } + + func textField(_: UITextField, shouldChangeCharactersIn _: NSRange, replacementString string: String) -> Bool + { + if let request = PaymentRequest(string: string) { + didReceivePaymentRequest?(request) + return false + } else { + return true + } + } +} diff --git a/litewallet/src/Views/SendViewCells/DescriptionSendCell.swift b/litewallet/src/Views/SendViewCells/DescriptionSendCell.swift new file mode 100644 index 000000000..80a08fbbd --- /dev/null +++ b/litewallet/src/Views/SendViewCells/DescriptionSendCell.swift @@ -0,0 +1,106 @@ +import UIKit + +class DescriptionSendCell: SendCell { + init(placeholder: String) { + super.init() + textView.delegate = self + textView.font = .customBody(size: 20.0) + textView.returnKeyType = .done + self.placeholder.text = placeholder + + if #available(iOS 11.0, *) { + guard let headerTextColor = UIColor(named: "headerTextColor"), + let textColor = UIColor(named: "labelTextColor") + else { + NSLog("ERROR: Custom color") + return + } + textView.textColor = textColor + self.placeholder.textColor = headerTextColor + } else { + textView.textColor = .darkText + } + + setupViews() + } + + var didBeginEditing: (() -> Void)? + var didReturn: ((UITextView) -> Void)? + var didChange: ((String) -> Void)? + var content: String? { + didSet { + textView.text = content + textViewDidChange(textView) + } + } + + var textView = UITextView() + fileprivate var placeholder = UILabel(font: .customBody(size: 16.0), color: .grayTextTint) + private func setupViews() { + textView.isScrollEnabled = false + + textView.clipsToBounds = true + textView.layer.cornerRadius = 8.0 + + addSubview(textView) + textView.constrain([ + textView.constraint(.leading, toView: self, constant: 11.0), + textView.centerYAnchor.constraint(equalTo: centerYAnchor), + textView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16.0), + textView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16.0), + ]) + + textView.addSubview(placeholder) + placeholder.constrain([ + placeholder.centerYAnchor.constraint(equalTo: textView.centerYAnchor), + placeholder.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: 16.0), + ]) + } + + func clearPlaceholder() { + placeholder.text = "" + placeholder.isHidden = true + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension DescriptionSendCell: UITextViewDelegate { + func textViewDidBeginEditing(_: UITextView) { + didBeginEditing?() + } + + func textViewDidChange(_ textView: UITextView) { + placeholder.isHidden = textView.text.utf8.count > 0 + if let text = textView.text { + didChange?(text) + } + } + + func textViewShouldEndEditing(_: UITextView) -> Bool { + return true + } + + func textView(_ textView: UITextView, shouldChangeTextIn _: NSRange, replacementText text: String) -> Bool + { + guard text.rangeOfCharacter(from: CharacterSet.newlines) == nil + else { + textView.resignFirstResponder() + return false + } + + let count = (textView.text ?? "").utf8.count + text.utf8.count + if count > C.maxMemoLength { + return false + } else { + return true + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + didReturn?(textView) + } +} diff --git a/litewallet/src/Views/SendViewCells/SendCell.swift b/litewallet/src/Views/SendViewCells/SendCell.swift new file mode 100644 index 000000000..fbd348226 --- /dev/null +++ b/litewallet/src/Views/SendViewCells/SendCell.swift @@ -0,0 +1,29 @@ +import UIKit + +class SendCell: UIView { + static let defaultHeight: CGFloat = 55.0 // 95.0 + + init() { + super.init(frame: .zero) + setupViews() + } + + let accessoryView = UIView() + let border = UIView(color: .secondaryShadow) + + private func setupViews() { + addSubview(accessoryView) + addSubview(border) + accessoryView.constrain([ + accessoryView.constraint(.top, toView: self), + accessoryView.constraint(.trailing, toView: self), + accessoryView.heightAnchor.constraint(equalToConstant: SendCell.defaultHeight), + ]) + border.constrainBottomCorners(height: 1.0) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/SeparatorCell.swift b/litewallet/src/Views/SeparatorCell.swift new file mode 100644 index 000000000..e33320584 --- /dev/null +++ b/litewallet/src/Views/SeparatorCell.swift @@ -0,0 +1,21 @@ +import UIKit + +class SeparatorCell: UITableViewCell { + override init(style: CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + let separator = UIView() + separator.backgroundColor = .secondaryShadow + addSubview(separator) + separator.constrain([ + separator.leadingAnchor.constraint(equalTo: leadingAnchor), + separator.bottomAnchor.constraint(equalTo: bottomAnchor), + separator.trailingAnchor.constraint(equalTo: trailingAnchor), + separator.heightAnchor.constraint(equalToConstant: 1.0), + ]) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/ShadowButton.swift b/litewallet/src/Views/ShadowButton.swift new file mode 100644 index 000000000..8b3468e1d --- /dev/null +++ b/litewallet/src/Views/ShadowButton.swift @@ -0,0 +1,254 @@ +import UIKit + +enum ButtonType { + case primary + case secondary + case tertiary + case blackTransparent + case search + case warning + case boldWarning + case flatWhite + case flatWhiteBorder + case flatLitecoinBlue +} + +let minTargetSize: CGFloat = 48.0 + +class ShadowButton: UIControl { + init(title: String, type: ButtonType) { + self.title = title + self.type = type + super.init(frame: .zero) + accessibilityLabel = title + setupViews() + } + + init(title: String, type: ButtonType, image: UIImage) { + self.title = title + self.type = type + self.image = image + super.init(frame: .zero) + accessibilityLabel = title + setupViews() + } + + var isToggleable = false + var title: String { + didSet { + label.text = title + } + } + + var image: UIImage? { + didSet { + imageView?.image = image + } + } + + private let type: ButtonType + private let container = UIView() + private let shadowView = UIView() + private let label = UILabel() + private let shadowYOffset: CGFloat = 4.0 + private let cornerRadius: CGFloat = 4.0 + private var imageView: UIImageView? + + override var isHighlighted: Bool { + didSet { + if isHighlighted { + UIView.animate(withDuration: 0.04, animations: { + let shrink = CATransform3DMakeScale(0.97, 0.97, 1.0) + let translate = CATransform3DTranslate(shrink, 0, 4.0, 0) + self.container.layer.transform = translate + }) + } else { + UIView.animate(withDuration: 0.04, animations: { + self.container.transform = CGAffineTransform.identity + }) + } + } + } + + override var isSelected: Bool { + didSet { + guard isToggleable else { return } + if type == .tertiary || type == .search { + if isSelected { + container.layer.borderColor = UIColor.primaryButton.cgColor + imageView?.tintColor = .primaryButton + label.textColor = .primaryButton + } else { + setColors() + } + } + } + } + + private func setupViews() { + addShadowView() + addContent() + setColors() + addTarget(self, action: #selector(ShadowButton.touchUpInside), for: .touchUpInside) + setContentCompressionResistancePriority(UILayoutPriority.required, for: .horizontal) + label.setContentCompressionResistancePriority(UILayoutPriority.required, for: .horizontal) + setContentHuggingPriority(UILayoutPriority.required, for: .horizontal) + label.setContentHuggingPriority(UILayoutPriority.required, for: .horizontal) + } + + private func addShadowView() { + addSubview(shadowView) + shadowView.constrain([ + NSLayoutConstraint(item: shadowView, attribute: .height, relatedBy: .equal, toItem: self, attribute: .height, multiplier: 0.5, constant: 0.0), + shadowView.constraint(.bottom, toView: self), + shadowView.constraint(.centerX, toView: self), + NSLayoutConstraint(item: shadowView, attribute: .width, relatedBy: .equal, toItem: self, attribute: .width, multiplier: 0.8, constant: 0.0), + ]) + shadowView.layer.cornerRadius = 4.0 + shadowView.layer.shadowOffset = CGSize(width: 0, height: 4) + shadowView.backgroundColor = .white + shadowView.isUserInteractionEnabled = false + } + + private func addContent() { + addSubview(container) + container.backgroundColor = .primaryButton + container.layer.cornerRadius = cornerRadius + container.isUserInteractionEnabled = false + container.constrain(toSuperviewEdges: nil) + label.text = title + label.textColor = .white + label.textAlignment = .center + label.isUserInteractionEnabled = false + label.font = UIFont.customMedium(size: 16.0) + configureContentType() + } + + private func configureContentType() { + if let icon = image { + setupImageOption(icon: icon) + } else { + setupLabelOnly() + } + } + + private func setupImageOption(icon: UIImage) { + let content = UIView() + let iconImageView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate)) + iconImageView.contentMode = .scaleAspectFit + container.addSubview(content) + content.addSubview(label) + content.addSubview(iconImageView) + content.constrainToCenter() + iconImageView.constrainLeadingCorners() + label.constrainTrailingCorners() + iconImageView.constrain([ + iconImageView.constraint(toLeading: label, constant: -C.padding[1]), + ]) + imageView = iconImageView + } + + private func setupLabelOnly() { + container.addSubview(label) + label.constrain(toSuperviewEdges: UIEdgeInsets(top: C.padding[1], left: C.padding[1], bottom: -C.padding[1], right: -C.padding[1])) + } + + private func setColors() { + switch type { + case .flatLitecoinBlue: + container.backgroundColor = .liteWalletBlue + label.textColor = .primaryText + container.layer.borderColor = UIColor.white.cgColor + container.layer.borderWidth = 1.0 + imageView?.tintColor = .white + shadowView.layer.shadowColor = UIColor.black.cgColor + shadowView.layer.shadowOpacity = 0.15 + case .flatWhite: + container.backgroundColor = .white + label.textColor = .liteWalletBlue + container.layer.borderColor = nil + container.layer.borderWidth = 0.0 + imageView?.tintColor = .liteWalletBlue + case .flatWhiteBorder: + container.backgroundColor = .white + label.textColor = .liteWalletBlue + container.layer.borderColor = UIColor.liteWalletBlue.cgColor + container.layer.borderWidth = 1.0 + imageView?.tintColor = .liteWalletBlue + case .primary: + container.backgroundColor = .primaryButton + label.textColor = .primaryText + container.layer.borderColor = nil + container.layer.borderWidth = 0.0 + shadowView.layer.shadowColor = UIColor.black.cgColor + shadowView.layer.shadowOpacity = 0.3 + imageView?.tintColor = .white + case .secondary: + container.backgroundColor = .secondaryButton + label.textColor = .darkText + container.layer.borderColor = UIColor.secondaryBorder.cgColor + container.layer.borderWidth = 1.0 + shadowView.layer.shadowColor = UIColor.black.cgColor + shadowView.layer.shadowOpacity = 0.15 + imageView?.tintColor = .darkText + case .tertiary: + container.backgroundColor = .secondaryButton + label.textColor = .grayTextTint + container.layer.borderColor = UIColor.secondaryBorder.cgColor + container.layer.borderWidth = 1.0 + shadowView.layer.shadowColor = UIColor.black.cgColor + shadowView.layer.shadowOpacity = 0.15 + imageView?.tintColor = .grayTextTint + case .blackTransparent: + container.backgroundColor = .clear + label.textColor = .darkText + container.layer.borderColor = UIColor.darkText.cgColor + container.layer.borderWidth = 1.0 + imageView?.tintColor = .grayTextTint + shadowView.isHidden = true + case .search: + label.font = UIFont.customBody(size: 13.0) + container.backgroundColor = .secondaryButton + label.textColor = .grayTextTint + container.layer.borderColor = UIColor.secondaryBorder.cgColor + container.layer.borderWidth = 1.0 + shadowView.layer.shadowColor = UIColor.black.cgColor + shadowView.layer.shadowOpacity = 0.15 + imageView?.tintColor = .grayTextTint + case .warning: + label.font = UIFont.customBody(size: 13.0) + container.backgroundColor = .pink + label.textColor = .white + container.layer.borderColor = UIColor.secondaryBorder.cgColor + container.layer.borderWidth = 1.0 + shadowView.layer.shadowColor = UIColor.black.cgColor + shadowView.layer.shadowOpacity = 0.15 + // imageView?.tintColor = .grayTextTint + case .boldWarning: + label.font = UIFont.customBold(size: 15.0) + container.backgroundColor = .pink + label.textColor = .white + container.layer.borderColor = UIColor.secondaryBorder.cgColor + container.layer.borderWidth = 1.0 + shadowView.layer.shadowColor = UIColor.black.cgColor + shadowView.layer.shadowOpacity = 0.15 + } + } + + override open func hitTest(_ point: CGPoint, with _: UIEvent?) -> UIView? { + guard !isHidden || isUserInteractionEnabled else { return nil } + let deltaX = max(minTargetSize - bounds.width, 0) + let deltaY = max(minTargetSize - bounds.height, 0) + let hitFrame = bounds.insetBy(dx: -deltaX / 2.0, dy: -deltaY / 2.0) + return hitFrame.contains(point) ? self : nil + } + + @objc private func touchUpInside() { + isSelected = !isSelected + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/SyncingView.swift b/litewallet/src/Views/SyncingView.swift new file mode 100644 index 000000000..a406f7a35 --- /dev/null +++ b/litewallet/src/Views/SyncingView.swift @@ -0,0 +1,106 @@ +import UIKit + +private let progressHeight: CGFloat = 8.0 + +class SyncingView: UIView { + init() { + super.init(frame: .zero) + setup() + } + + var progress: CGFloat = 0.0 { + didSet { + progressForegroundWidth?.isActive = false + progressForegroundWidth = progressForeground.widthAnchor.constraint(equalTo: progressBackground.widthAnchor, multiplier: progress) + progressForegroundWidth?.isActive = true + progressForeground.setNeedsDisplay() + } + } + + var timestamp: UInt32 = 0 { + didSet { + date.text = dateFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp))) + } + } + + func setIsConnecting() { + header.text = S.SyncingView.connecting.localize() + date.text = "" + } + + func reset() { + setInitialData() + } + + private let header = UILabel(font: .customBold(size: 14.0)) + private let date = UILabel(font: .customBody(size: 13.0)) + + private let progressBackground: UIView = { + let view = UIView() + view.layer.cornerRadius = progressHeight / 2.0 + view.layer.masksToBounds = true + view.backgroundColor = .secondaryShadow + return view + }() + + private let progressForeground: UIView = { + let view = GradientView() + view.layer.cornerRadius = progressHeight / 2.0 + view.layer.masksToBounds = true + return view + }() + + private let dateFormatter: DateFormatter = { + let df = DateFormatter() + df.setLocalizedDateFormatFromTemplate("MMM d, yyyy") + return df + }() + + private var progressForegroundWidth: NSLayoutConstraint? + + private func setup() { + addSubview(header) + addSubview(date) + addSubview(progressBackground) + + progressBackground.addSubview(progressForeground) + + header.constrain([ + header.leadingAnchor.constraint(equalTo: leadingAnchor, constant: C.padding[2]), + header.topAnchor.constraint(equalTo: topAnchor, constant: C.padding[2]), + ]) + + progressBackground.constrain([ + progressBackground.leadingAnchor.constraint(equalTo: header.leadingAnchor), + progressBackground.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -C.padding[2]), + progressBackground.topAnchor.constraint(equalTo: header.bottomAnchor, constant: C.padding[2]), + progressBackground.heightAnchor.constraint(equalToConstant: progressHeight), + ]) + + date.constrain([ + date.leadingAnchor.constraint(equalTo: progressBackground.leadingAnchor), + date.topAnchor.constraint(equalTo: progressBackground.bottomAnchor, constant: C.padding[1]), + ]) + + progressForegroundWidth = progressForeground.widthAnchor.constraint(equalTo: progressBackground.widthAnchor, multiplier: progress) + progressForeground.constrain([ + progressForegroundWidth, + progressForeground.leadingAnchor.constraint(equalTo: progressBackground.leadingAnchor), + progressForeground.centerYAnchor.constraint(equalTo: progressBackground.centerYAnchor), + progressForeground.heightAnchor.constraint(equalTo: progressBackground.heightAnchor), + ]) + + setInitialData() + } + + private func setInitialData() { + header.text = S.SyncingView.syncing.localize() + header.textColor = .darkText + date.text = "" + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/UnEditableTextView.swift b/litewallet/src/Views/UnEditableTextView.swift new file mode 100644 index 000000000..ed91bb69b --- /dev/null +++ b/litewallet/src/Views/UnEditableTextView.swift @@ -0,0 +1,7 @@ +import UIKit + +class UnEditableTextView: UITextView { + override var canBecomeFirstResponder: Bool { + return false + } +} diff --git a/litewallet/src/Views/UpdatingLabel.swift b/litewallet/src/Views/UpdatingLabel.swift new file mode 100644 index 000000000..953ec93f3 --- /dev/null +++ b/litewallet/src/Views/UpdatingLabel.swift @@ -0,0 +1,79 @@ +import UIKit + +class UpdatingLabel: UILabel { + var formatter: NumberFormatter { + didSet { + setFormattedText(forValue: value) + } + } + + init(formatter: NumberFormatter) { + self.formatter = formatter + super.init(frame: .zero) + text = self.formatter.string(from: 0 as NSNumber) + } + + var completion: (() -> Void)? + private var value: Double = 0.0 + + func setValue(_ value: Double) { + self.value = value + setFormattedText(forValue: value) + } + + func setValueAnimated(_ endingValue: Double, completion: @escaping () -> Void) { + self.completion = completion + guard let currentText = text else { return } + guard let startingValue = formatter.number(from: currentText)?.doubleValue else { return } + self.startingValue = startingValue + self.endingValue = endingValue + + timer?.invalidate() + lastUpdate = CACurrentMediaTime() + progress = 0.0 + + startTimer() + } + + private let duration = 0.6 + private var easingRate: Double = 3.0 + private var timer: CADisplayLink? + private var startingValue: Double = 0.0 + private var endingValue: Double = 0.0 + private var progress: Double = 0.0 + private var lastUpdate: CFTimeInterval = 0.0 + + private func startTimer() { + timer = CADisplayLink(target: self, selector: #selector(UpdatingLabel.update)) + timer?.preferredFramesPerSecond = 2 + timer?.add(to: .main, forMode: .default) + timer?.add(to: .main, forMode: .tracking) + } + + @objc private func update() { + let now = CACurrentMediaTime() + progress = progress + (now - lastUpdate) + lastUpdate = now + if progress >= duration { + timer?.invalidate() + timer = nil + setFormattedText(forValue: endingValue) + completion?() + } else { + let percentProgress = progress / duration + let easedVal = 1.0 - pow(1.0 - percentProgress, easingRate) + setFormattedText(forValue: startingValue + (easedVal * (endingValue - startingValue))) + } + } + + private func setFormattedText(forValue: Double) { + value = forValue + text = formatter.string(from: forValue as NSNumber) + sizeToFit() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Views/WalletDisabledView.swift b/litewallet/src/Views/WalletDisabledView.swift new file mode 100644 index 000000000..4a2304c42 --- /dev/null +++ b/litewallet/src/Views/WalletDisabledView.swift @@ -0,0 +1,84 @@ +import UIKit + +class WalletDisabledView: UIView { + func setTimeLabel(string: String) { + label.text = string + } + + init(store: Store) { + self.store = store + faq = UIButton.buildFaqButton(store: store, articleId: ArticleIds.nothing) + blur = UIVisualEffectView() + super.init(frame: .zero) + setup() + } + + func show() { + UIView.animate(withDuration: C.animationDuration, animations: { + self.blur.effect = self.effect + }) + } + + func hide(completion: @escaping () -> Void) { + UIView.animate(withDuration: C.animationDuration, animations: { + self.blur.effect = nil + }, completion: { _ in + completion() + }) + } + + var didTapReset: (() -> Void)? { + didSet { + reset.tap = didTapReset + } + } + + private let label = UILabel(font: .customBold(size: 20.0), color: .darkText) + private let store: Store + private let faq: UIButton + private let blur: UIVisualEffectView + private let reset = ShadowButton(title: S.UnlockScreen.resetPin.localize(), type: .blackTransparent) + private let effect = UIBlurEffect(style: .light) + + private func setup() { + addSubviews() + addConstraints() + setData() + } + + private func addSubviews() { + addSubview(blur) + addSubview(label) + addSubview(faq) + addSubview(reset) + } + + private func addConstraints() { + blur.constrain(toSuperviewEdges: nil) + label.constrain([ + label.centerYAnchor.constraint(equalTo: blur.centerYAnchor), + label.centerXAnchor.constraint(equalTo: blur.centerXAnchor), + ]) + faq.constrain([ + faq.leadingAnchor.constraint(equalTo: leadingAnchor, constant: C.padding[2]), + faq.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -C.padding[2]), + faq.widthAnchor.constraint(equalToConstant: 44.0), + faq.heightAnchor.constraint(equalToConstant: 44.0), + ]) + reset.constrain([ + reset.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -C.padding[2]), + reset.centerYAnchor.constraint(equalTo: faq.centerYAnchor), + reset.heightAnchor.constraint(equalToConstant: C.Sizes.buttonHeight), + reset.widthAnchor.constraint(equalToConstant: 200.0), + ]) + } + + private func setData() { + label.textAlignment = .center + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/litewallet/src/Wallet/ExchangeUpdater.swift b/litewallet/src/Wallet/ExchangeUpdater.swift new file mode 100644 index 000000000..5b52eb0a0 --- /dev/null +++ b/litewallet/src/Wallet/ExchangeUpdater.swift @@ -0,0 +1,30 @@ +import Foundation + +class ExchangeUpdater: Subscriber { + // MARK: - Public + + init(store: Store, walletManager: WalletManager) { + self.store = store + self.walletManager = walletManager + store.subscribe(self, + selector: { $0.defaultCurrencyCode != $1.defaultCurrencyCode }, + callback: { state in + guard let currentRate = state.rates.first(where: { $0.code == state.defaultCurrencyCode }) else { return } + self.store.perform(action: ExchangeRates.setRate(currentRate)) + }) + } + + func refresh(completion: @escaping () -> Void) { + walletManager.apiClient?.exchangeRates { rates, _ in + + guard let currentRate = rates.first(where: { $0.code == self.store.state.defaultCurrencyCode }) else { completion(); return } + self.store.perform(action: ExchangeRates.setRates(currentRate: currentRate, rates: rates)) + completion() + } + } + + // MARK: - Private + + let store: Store + let walletManager: WalletManager +} diff --git a/litewallet/src/WalletCoordinator.swift b/litewallet/src/WalletCoordinator.swift new file mode 100644 index 000000000..83695f719 --- /dev/null +++ b/litewallet/src/WalletCoordinator.swift @@ -0,0 +1,279 @@ +import AVFoundation +import Foundation +import UIKit + +private let lastBlockHeightKey = "LastBlockHeightKey" +private let progressUpdateInterval: TimeInterval = 0.5 +private let updateDebounceInterval: TimeInterval = 0.4 + +class WalletCoordinator: Subscriber, Trackable { + var kvStore: BRReplicatedKVStore? { + didSet { + requestTxUpdate() + } + } + + private let walletManager: WalletManager + private let store: Store + private var progressTimer: Timer? + private var updateTimer: Timer? + private let defaults = UserDefaults.standard + private var backgroundTaskId: UIBackgroundTaskIdentifier? + private var reachability = ReachabilityMonitor() + private var retryTimer: RetryTimer? + + init(walletManager: WalletManager, store: Store) { + self.walletManager = walletManager + self.store = store + addWalletObservers() + addSubscriptions() + updateBalance() + reachability.didChange = { [weak self] isReachable in + self?.reachabilityDidChange(isReachable: isReachable) + } + } + + private var lastBlockHeight: UInt32 { + set { + defaults.set(newValue, forKey: lastBlockHeightKey) + } + get { + return UInt32(defaults.integer(forKey: lastBlockHeightKey)) + } + } + + @objc private func updateProgress() { + DispatchQueue.walletQueue.async { + guard let progress = self.walletManager.peerManager?.syncProgress(fromStartHeight: self.lastBlockHeight), let timestamp = self.walletManager.peerManager?.lastBlockTimestamp else { return } + DispatchQueue.main.async { + self.store.perform(action: WalletChange.setProgress(progress: progress, timestamp: timestamp)) + } + } + updateBalance() + } + + private func onSyncStart() { + endBackgroundTask() + startBackgroundTask() + progressTimer = Timer.scheduledTimer(timeInterval: progressUpdateInterval, target: self, selector: #selector(WalletCoordinator.updateProgress), userInfo: nil, repeats: true) + store.perform(action: WalletChange.setSyncingState(.syncing)) + startActivity() + } + + private func onSyncStop(notification: Notification) { + if UIApplication.shared.applicationState != .active { + DispatchQueue.walletQueue.async { + self.walletManager.peerManager?.disconnect() + } + } + endBackgroundTask() + if notification.userInfo != nil { + guard let code = notification.userInfo?["errorCode"] else { return } + guard let message = notification.userInfo?["errorDescription"] else { return } + store.perform(action: WalletChange.setSyncingState(.connecting)) + saveEvent("event.syncErrorMessage", attributes: ["message": "\(message) (\(code))"]) + endActivity() + + if retryTimer == nil, reachability.isReachable { + retryTimer = RetryTimer() + retryTimer?.callback = strongify(self) { myself in + myself.store.trigger(name: .retrySync) + } + retryTimer?.start() + } + + return + } + retryTimer?.stop() + retryTimer = nil + if let height = walletManager.peerManager?.lastBlockHeight { + lastBlockHeight = height + } + progressTimer?.invalidate() + progressTimer = nil + store.perform(action: WalletChange.setSyncingState(.success)) + endActivity() + } + + private func endBackgroundTask() { + if let taskId = backgroundTaskId { + UIApplication.shared.endBackgroundTask(taskId) + backgroundTaskId = nil + } + } + + private func startBackgroundTask() { + backgroundTaskId = UIApplication.shared.beginBackgroundTask(expirationHandler: { + DispatchQueue.walletQueue.async { + self.walletManager.peerManager?.disconnect() + } + }) + } + + private func requestTxUpdate() { + if updateTimer == nil { + updateTimer = Timer.scheduledTimer(timeInterval: updateDebounceInterval, target: self, selector: #selector(updateTransactions), userInfo: nil, repeats: false) + } + } + + @objc private func updateTransactions() { + updateTimer?.invalidate() + updateTimer = nil + DispatchQueue.walletQueue.async { + guard let txRefs = self.walletManager.wallet?.transactions else { return } + let transactions = self.makeTransactionViewModels(transactions: txRefs, walletManager: self.walletManager, kvStore: self.kvStore, rate: self.store.state.currentRate) + if !transactions.isEmpty { + DispatchQueue.main.async { + self.store.perform(action: WalletChange.setTransactions(transactions)) + } + } + } + } + + func makeTransactionViewModels(transactions: [BRTxRef?], walletManager: WalletManager, kvStore: BRReplicatedKVStore?, rate: Rate?) -> [Transaction] + { + return transactions.compactMap { $0 }.sorted { + if $0.pointee.timestamp == 0 { + return true + } else if $1.pointee.timestamp == 0 { + return false + } else { + return $0.pointee.timestamp > $1.pointee.timestamp + } + }.compactMap { + Transaction($0, walletManager: walletManager, kvStore: kvStore, rate: rate) + } + } + + private func addWalletObservers() { + weak var myself = self + NotificationCenter.default.addObserver(forName: .walletBalanceChangedNotification, object: nil, queue: nil, using: { + _ in + myself?.updateBalance() + myself?.requestTxUpdate() + }) + + NotificationCenter.default.addObserver(forName: .walletTxStatusUpdateNotification, object: nil, queue: nil, using: { _ in + myself?.requestTxUpdate() + }) + + NotificationCenter.default.addObserver(forName: .walletTxRejectedNotification, object: nil, queue: nil, using: { note in + guard let recommendRescan = note.userInfo?["recommendRescan"] as? Bool else { return } + myself?.requestTxUpdate() + if recommendRescan { + myself?.store.perform(action: RecommendRescan.set(recommendRescan)) + } + }) + + NotificationCenter.default.addObserver(forName: .walletSyncStartedNotification, object: nil, queue: nil, using: { _ in + myself?.onSyncStart() + }) + + NotificationCenter.default.addObserver(forName: .walletSyncStoppedNotification, object: nil, queue: nil, using: { note in + myself?.onSyncStop(notification: note) + }) + + NotificationCenter.default.addObserver(forName: .languageChangedNotification, object: nil, queue: nil, using: { _ in + myself?.updateTransactions() + }) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func updateBalance() { + DispatchQueue.walletQueue.async { + guard let newBalance = self.walletManager.wallet?.balance else { return } + DispatchQueue.main.async { + self.checkForReceived(newBalance: newBalance) + self.store.perform(action: WalletChange.setBalance(newBalance)) + } + } + } + + private func checkForReceived(newBalance: UInt64) { + if let oldBalance = store.state.walletState.balance { + if newBalance > oldBalance { + if store.state.walletState.syncState == .success { + showReceived(amount: newBalance - oldBalance) + } + } + } + } + + private func showReceived(amount: UInt64) { + if let rate = store.state.currentRate { + let amount = Amount(amount: amount, rate: rate, maxDigits: store.state.maxDigits) + let primary = store.state.isLtcSwapped ? amount.localCurrency : amount.bits + let secondary = store.state.isLtcSwapped ? amount.bits : amount.localCurrency + let message = String(format: S.TransactionDetails.received.localize(), "\(primary) (\(secondary))") + store.trigger(name: .lightWeightAlert(message)) + showLocalNotification(message: message) + ping() + } + } + + private func ping() { + if let url = Bundle.main.url(forResource: "coinflip", withExtension: "aiff") { + var id: SystemSoundID = 0 + AudioServicesCreateSystemSoundID(url as CFURL, &id) + AudioServicesAddSystemSoundCompletion(id, nil, nil, { soundId, _ in + AudioServicesDisposeSystemSoundID(soundId) + }, nil) + AudioServicesPlaySystemSound(id) + } + } + + private func showLocalNotification(message: String) { + guard UIApplication.shared.applicationState == .background || UIApplication.shared.applicationState == .inactive else { return } + guard store.state.isPushNotificationsEnabled else { return } + UIApplication.shared.applicationIconBadgeNumber = UIApplication.shared.applicationIconBadgeNumber + 1 + let notification = + UILocalNotification() + notification.alertBody = message + notification.soundName = "coinflip.aiff" + UIApplication.shared.presentLocalNotificationNow(notification) + } + + private func reachabilityDidChange(isReachable: Bool) { + if !isReachable { + DispatchQueue.walletQueue.async { + self.walletManager.peerManager?.disconnect() + DispatchQueue.main.async { + self.store.perform(action: WalletChange.setSyncingState(.connecting)) + } + } + } + } + + private func addSubscriptions() { + store.subscribe(self, name: .retrySync, callback: { _ in + DispatchQueue.walletQueue.async { + self.walletManager.peerManager?.connect() + } + }) + + store.subscribe(self, name: .rescan, callback: { _ in + self.store.perform(action: RecommendRescan.set(false)) + // In case rescan is called while a sync is in progess + // we need to make sure it's false before a rescan starts + // self.store.perform(action: WalletChange.setIsSyncing(false)) + DispatchQueue.walletQueue.async { + self.walletManager.peerManager?.rescan() + } + }) + + store.subscribe(self, name: .rescan, callback: { _ in + self.store.perform(action: WalletChange.setIsRescanning(true)) + }) + } + + private func startActivity() { + UIApplication.shared.isIdleTimerDisabled = true + } + + private func endActivity() { + UIApplication.shared.isIdleTimerDisabled = false + } +} diff --git a/litewallet/src/WalletManager+Auth.swift b/litewallet/src/WalletManager+Auth.swift new file mode 100644 index 000000000..6393cc4fc --- /dev/null +++ b/litewallet/src/WalletManager+Auth.swift @@ -0,0 +1,611 @@ +import BRCore +import FirebaseAnalytics +import Foundation +import LocalAuthentication +import SQLite3 +import UIKit + +private let WalletSecAttrService = "com.litecoin.loafwallet" +private let BIP39CreationTime = TimeInterval(BIP39_CREATION_TIME) - NSTimeIntervalSince1970 + +/// WalletAuthenticator is a protocol whose implementors are able to interact with wallet authentication +public protocol WalletAuthenticator { + var noWallet: Bool { get } + var apiAuthKey: String? { get } + var userAccount: [AnyHashable: Any]? { get set } +} + +struct NoAuthAuthenticator: WalletAuthenticator { + let noWallet = true + let apiAuthKey: String? = nil + var userAccount: [AnyHashable: Any]? = nil +} + +enum BiometricsResult { + case success + case cancel + case fallback + case failure +} + +enum TransferCardResult { + case success + case cancel + case fallback + case failure +} + +extension WalletManager: WalletAuthenticator { + private static var failedPins = [String]() + + convenience init(store: Store, dbPath: String? = nil) throws { + if !UIApplication.shared.isProtectedDataAvailable { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(errSecNotAvailable)) + } + + if try keychainItem(key: KeychainKey.seed) as Data? != nil { // upgrade from old keychain scheme + let seedPhrase: String? = try keychainItem(key: KeychainKey.mnemonic) + var seed = UInt512() + print("upgrading to authenticated keychain scheme") + BRBIP39DeriveKey(&seed, seedPhrase, nil) + let mpk = BRBIP32MasterPubKey(&seed, MemoryLayout.size) + seed = UInt512() // clear seed + try setKeychainItem(key: KeychainKey.mnemonic, item: seedPhrase, authenticated: true) + try setKeychainItem(key: KeychainKey.masterPubKey, item: Data(masterPubKey: mpk)) + try setKeychainItem(key: KeychainKey.seed, item: nil as Data?) + } + + let mpkData: Data? = try keychainItem(key: KeychainKey.masterPubKey) + guard let masterPubKey = mpkData?.masterPubKey + else { + try self.init(masterPubKey: BRMasterPubKey(), + earliestKeyTime: 0, + dbPath: dbPath, + store: store, + fpRate: FalsePositiveRates.semiPrivate.rawValue) + return + } + + var earliestKeyTime = BIP39CreationTime + if let creationTime: Data = try keychainItem(key: KeychainKey.creationTime), + creationTime.count == MemoryLayout.stride + { + creationTime.withUnsafeBytes { earliestKeyTime = $0.pointee } + } + + try self.init(masterPubKey: masterPubKey, + earliestKeyTime: earliestKeyTime, + dbPath: dbPath, + store: store, + fpRate: FalsePositiveRates.semiPrivate.rawValue) + } + + // true if keychain is available and we know that no wallet exists on it + var noWallet: Bool { + if didInitWallet { return false } + return WalletManager.staticNoWallet + } + + static var staticNoWallet: Bool { + do { + if try keychainItem(key: KeychainKey.masterPubKey) as Data? != nil { return false } + if try keychainItem(key: KeychainKey.seed) as Data? != nil { return false } // check for old keychain scheme + return true + } catch { return false } + } + + // Login with pin should be required if the pin hasn't been used within a week + var pinLoginRequired: Bool { + let pinUnlockTime = UserDefaults.standard.double(forKey: DefaultsKey.pinUnlockTime) + let now = Date.timeIntervalSinceReferenceDate + let secondsInWeek = 60.0 * 60.0 * 24.0 * 7.0 + return now - pinUnlockTime > secondsInWeek + } + + // true if the given transaction can be signed with biometric authentication + func canUseBiometrics(forTx: BRTxRef) -> Bool { + guard LAContext.canUseBiometrics else { return false } + + do { + let spendLimit: Int64 = try keychainItem(key: KeychainKey.spendLimit) ?? 0 + guard let wallet = wallet else { assertionFailure("No wallet!"); return false } + return wallet.amountSentByTx(forTx) - wallet.amountReceivedFromTx(forTx) + wallet.totalSent <= UInt64(spendLimit) + } catch { return false } + } + + var spendingLimit: UInt64 { + get { + guard UserDefaults.standard.object(forKey: DefaultsKey.spendLimitAmount) != nil + else { + return 0 + } + return UInt64(UserDefaults.standard.double(forKey: DefaultsKey.spendLimitAmount)) + } + set { + guard let wallet = wallet + else { + assertionFailure("No wallet!") + return + } + do { + try setKeychainItem(key: KeychainKey.spendLimit, item: Int64(wallet.totalSent + newValue)) + UserDefaults.standard.set(newValue, forKey: DefaultsKey.spendLimitAmount) + } catch { + print("Set spending limit error: \(error)") + } + } + } + + // number of unique failed pin attempts remaining before wallet is wiped + var pinAttemptsRemaining: Int { + do { + let failCount: Int64 = try keychainItem(key: KeychainKey.pinFailCount) ?? 0 + return Int(8 - failCount) + } catch { return -1 } + } + + // after 3 or more failed pin attempts, authentication is disabled until this time (interval since reference date) + var walletDisabledUntil: TimeInterval { + do { + let failCount: Int64 = try keychainItem(key: KeychainKey.pinFailCount) ?? 0 + guard failCount >= 3 else { return 0 } + let failTime: Int64 = try keychainItem(key: KeychainKey.pinFailTime) ?? 0 + return Double(failTime) + pow(6, Double(failCount - 3)) * 60 + } catch { + assertionFailure("Error: \(error)") + return 0 + } + } + + // Can be expensive...result should be cached + var pinLength: Int { + do { + if let pin: String = try keychainItem(key: KeychainKey.pin) { + return pin.utf8.count + } else { + return 6 + } + } catch { + print("Pin keychain error: \(error)") + return 6 + } + } + + // true if pin is correct + func authenticate(pin: String) -> Bool { + do { + let secureTime = Date().timeIntervalSince1970 // TODO: XXX use secure time from https request + var failCount: Int64 = try keychainItem(key: KeychainKey.pinFailCount) ?? 0 + + if failCount >= 3 { + let failTime: Int64 = try keychainItem(key: KeychainKey.pinFailTime) ?? 0 + + if secureTime < Double(failTime) + pow(6, Double(failCount - 3)) * 60 { // locked out + return false + } + } + + if !WalletManager.failedPins.contains(pin) { // count unique attempts before checking success + failCount += 1 + try setKeychainItem(key: KeychainKey.pinFailCount, item: failCount) + } + + if try pin == keychainItem(key: KeychainKey.pin) { // successful pin attempt + try authenticationSuccess() + return true + } else if !WalletManager.failedPins.contains(pin) { // unique failed attempt + WalletManager.failedPins.append(pin) + + if failCount >= 8 { // wipe wallet after 8 failed pin attempts and 24+ hours of lockout + if !wipeWallet() { return false } + return false + } + let pinFailTime: Int64 = try keychainItem(key: KeychainKey.pinFailTime) ?? 0 + if secureTime > Double(pinFailTime) { + try setKeychainItem(key: KeychainKey.pinFailTime, item: Int64(secureTime)) + } + } + + return false + } catch { + assertionFailure("Error: \(error)") + return false + } + } + + // true if phrase is correct + func authenticate(phrase: String) -> Bool { + do { + var seed = UInt512() + guard let nfkdPhrase = CFStringCreateMutableCopy(secureAllocator, 0, phrase as CFString) + else { return false } + CFStringNormalize(nfkdPhrase, .KD) + BRBIP39DeriveKey(&seed, nfkdPhrase as String, nil) + let mpk = BRBIP32MasterPubKey(&seed, MemoryLayout.size) + seed = UInt512() // clear seed + let mpkData: Data? = try keychainItem(key: KeychainKey.masterPubKey) + guard mpkData?.masterPubKey == mpk else { return false } + return true + } catch { + return false + } + } + + private func authenticationSuccess() throws { + let limit = Int64(UserDefaults.standard.double(forKey: DefaultsKey.spendLimitAmount)) + + WalletManager.failedPins.removeAll() + UserDefaults.standard.set(Date.timeIntervalSinceReferenceDate, forKey: DefaultsKey.pinUnlockTime) + try setKeychainItem(key: KeychainKey.pinFailTime, item: Int64(0)) + try setKeychainItem(key: KeychainKey.pinFailCount, item: Int64(0)) + + if let wallet = wallet, limit > 0 { + try setKeychainItem(key: KeychainKey.spendLimit, + item: Int64(wallet.totalSent) + limit) + } + } + + // show biometric dialog and call completion block with success or failure + func authenticate(biometricsPrompt: String, completion: @escaping (BiometricsResult) -> Void) { + let policy = LAPolicy.deviceOwnerAuthenticationWithBiometrics + LAContext().evaluatePolicy(policy, localizedReason: biometricsPrompt, + reply: { success, error in + DispatchQueue.main.async { + if success { return completion(.success) } + guard let error = error else { return completion(.failure) } + if error._code == Int(kLAErrorUserCancel) { + return completion(.cancel) + } else if error._code == Int(kLAErrorUserFallback) { + return completion(.fallback) + } + completion(.failure) + } + }) + } + + // sign the given transaction using pin authentication + func signTransaction(_ tx: BRTxRef, forkId: Int = 0, pin: String) -> Bool { + guard authenticate(pin: pin) else { return false } + return signTx(tx, forkId: forkId) + } + + // sign the given transaction using biometric authentication + func signTransaction(_ tx: BRTxRef, biometricsPrompt: String, completion: @escaping (BiometricsResult) -> Void) + { + do { + let spendLimit: Int64 = try keychainItem(key: KeychainKey.spendLimit) ?? 0 + guard let wallet = wallet, wallet.amountSentByTx(tx) - wallet.amountReceivedFromTx(tx) + wallet.totalSent <= UInt64(spendLimit) + else { + return completion(.failure) + } + } catch { return completion(.failure) } + store.perform(action: biometricsActions.setIsPrompting(true)) + authenticate(biometricsPrompt: biometricsPrompt) { result in + self.store.perform(action: biometricsActions.setIsPrompting(false)) + guard result == .success else { return completion(result) } + completion(self.signTx(tx) == true ? .success : .failure) + } + } + + // the 12 word wallet recovery phrase + func seedPhrase(pin: String) -> String? { + guard authenticate(pin: pin) else { return nil } + + do { + return try keychainItem(key: KeychainKey.mnemonic) + } catch { return nil } + } + + // recover an existing wallet using 12 word wallet recovery phrase + // will fail if a wallet already exists on the keychain + func setSeedPhrase(_ phrase: String) -> Bool { + guard noWallet else { return false } + + do { + guard let nfkdPhrase = CFStringCreateMutableCopy(secureAllocator, 0, phrase as CFString) + else { return false } + CFStringNormalize(nfkdPhrase, .KD) + var seed = UInt512() + try setKeychainItem(key: KeychainKey.mnemonic, item: nfkdPhrase as String?, authenticated: true) + BRBIP39DeriveKey(&seed, nfkdPhrase as String, nil) + masterPubKey = BRBIP32MasterPubKey(&seed, MemoryLayout.size) + seed = UInt512() // clear seed + if earliestKeyTime < BIP39CreationTime { earliestKeyTime = BIP39CreationTime } + try setKeychainItem(key: KeychainKey.masterPubKey, item: Data(masterPubKey: masterPubKey)) + return true + } catch { return false } + } + + // create a new wallet and return the 12 word wallet recovery phrase + // will fail if a wallet already exists on the keychain + func setRandomSeedPhrase() -> String? { + guard noWallet else { return nil } + guard var words = rawWordList else { return nil } + let time = Date.timeIntervalSinceReferenceDate + + // we store the wallet creation time on the keychain because keychain data persists even when app is deleted + do { + try setKeychainItem(key: KeychainKey.creationTime, + item: [time].withUnsafeBufferPointer { Data(buffer: $0) }) + earliestKeyTime = time + } catch { return nil } + + // wrapping in an autorelease pool ensures sensitive memory is wiped and released immediately + return autoreleasepool { + var entropy = UInt128() + let entropyRef = UnsafeMutableRawPointer(mutating: &entropy).assumingMemoryBound(to: UInt8.self) + guard SecRandomCopyBytes(kSecRandomDefault, MemoryLayout.size, entropyRef) == 0 + else { return nil } + let phraseLen = BRBIP39Encode(nil, 0, &words, entropyRef, MemoryLayout.size) + var phraseData = CFDataCreateMutable(secureAllocator, phraseLen) as Data + phraseData.count = phraseLen + guard phraseData.withUnsafeMutableBytes({ + BRBIP39Encode($0, phraseLen, &words, entropyRef, MemoryLayout.size) + }) == phraseData.count else { return nil } + entropy = UInt128() + let phrase = CFStringCreateFromExternalRepresentation(secureAllocator, phraseData as CFData, + CFStringBuiltInEncodings.UTF8.rawValue) as String + guard setSeedPhrase(phrase) else { return nil } + return phrase + } + } + + // change wallet authentication pin + func changePin(newPin: String, pin: String) -> Bool { + guard authenticate(pin: pin) else { return false } + do { + store.perform(action: PinLength.set(newPin.utf8.count)) + try setKeychainItem(key: KeychainKey.pin, item: newPin) + return true + } catch { return false } + } + + // change wallet authentication pin using the wallet recovery phrase + // recovery phrase is optional if no pin is currently set + func forceSetPin(newPin: String, seedPhrase: String? = nil) -> Bool { + do { + if let phrase = seedPhrase { + var seed = UInt512() + guard let nfkdPhrase = CFStringCreateMutableCopy(secureAllocator, 0, phrase as CFString) + else { return false } + CFStringNormalize(nfkdPhrase, .KD) + BRBIP39DeriveKey(&seed, nfkdPhrase as String, nil) + let mpk = BRBIP32MasterPubKey(&seed, MemoryLayout.size) + seed = UInt512() // clear seed + let mpkData: Data? = try keychainItem(key: KeychainKey.masterPubKey) + guard mpkData?.masterPubKey == mpk else { return false } + } else if try keychainItem(key: KeychainKey.pin) as String? != nil { + return authenticate(pin: newPin) + } + store.perform(action: PinLength.set(newPin.utf8.count)) + try setKeychainItem(key: KeychainKey.pin, item: newPin) + try authenticationSuccess() + return true + } catch { return false } + } + + // wipe the existing wallet from the keychain + func wipeWallet(pin: String = "forceWipe") -> Bool { + guard pin == "forceWipe" || authenticate(pin: pin) else { return false } + + do { + lazyWallet = nil + lazyPeerManager = nil + if db != nil { sqlite3_close(db) } + db = nil + masterPubKey = BRMasterPubKey() + didInitWallet = false + earliestKeyTime = 0 + if let bundleId = Bundle.main.bundleIdentifier { + UserDefaults.standard.removePersistentDomain(forName: bundleId) + } + try BRAPIClient(authenticator: self).kv?.rmdb() + try? FileManager.default.removeItem(atPath: dbPath) + try? FileManager.default.removeItem(at: BRReplicatedKVStore.dbPath) + try setKeychainItem(key: KeychainKey.apiAuthKey, item: nil as Data?) + try setKeychainItem(key: KeychainKey.spendLimit, item: nil as Int64?) + try setKeychainItem(key: KeychainKey.creationTime, item: nil as Data?) + try setKeychainItem(key: KeychainKey.pinFailTime, item: nil as Int64?) + try setKeychainItem(key: KeychainKey.pinFailCount, item: nil as Int64?) + try setKeychainItem(key: KeychainKey.pin, item: nil as String?) + try setKeychainItem(key: KeychainKey.masterPubKey, item: nil as Data?) + try setKeychainItem(key: KeychainKey.seed, item: nil as Data?) + try setKeychainItem(key: KeychainKey.mnemonic, item: nil as String?, authenticated: true) + NotificationCenter.default.post(name: .walletDidWipeNotification, object: nil) + return true + } catch { + print("Wipe wallet error: \(error)") + return false + } + } + + func deleteWalletDatabase(pin: String = "forceWipe") -> Bool { + guard pin == "forceWipe" || authenticate(pin: pin) else { return false } + + do { + lazyWallet = nil + lazyPeerManager = nil + if db != nil { sqlite3_close(db) } + db = nil + didInitWallet = false + earliestKeyTime = 0 + if let bundleId = Bundle.main.bundleIdentifier { + UserDefaults.standard.removePersistentDomain(forName: bundleId) + } + try BRAPIClient(authenticator: self).kv?.rmdb() + try? FileManager.default.removeItem(atPath: dbPath) + try? FileManager.default.removeItem(at: BRReplicatedKVStore.dbPath) + NotificationCenter.default.post(name: .didDeleteWalletDBNotification, object: nil) + return true + } catch { + print("Wipe wallet error: \(error)") + return false + } + } + + // key used for authenticated API calls + var apiAuthKey: String? { + return autoreleasepool { + do { + if let apiKey: String? = try? keychainItem(key: KeychainKey.apiAuthKey) { + if apiKey != nil { + return apiKey + } + } + var key = BRKey() + var seed = UInt512() + guard let phrase: String = try keychainItem(key: KeychainKey.mnemonic) else { return nil } + BRBIP39DeriveKey(&seed, phrase, nil) + BRBIP32APIAuthKey(&key, &seed, MemoryLayout.size) + seed = UInt512() // clear seed + let pkLen = BRKeyPrivKey(&key, nil, 0) + var pkData = CFDataCreateMutable(secureAllocator, pkLen) as Data + pkData.count = pkLen + guard pkData.withUnsafeMutableBytes({ BRKeyPrivKey(&key, $0, pkLen) }) == pkLen else { return nil } + let privKey = CFStringCreateFromExternalRepresentation(secureAllocator, pkData as CFData, + CFStringBuiltInEncodings.UTF8.rawValue) as String + try setKeychainItem(key: KeychainKey.apiAuthKey, item: privKey) + return privKey + } catch { + print("apiAuthKey error: \(error)") + return nil + } + } + } + + // sensitive user information stored on the keychain + var userAccount: [AnyHashable: Any]? { + get { + do { + return try keychainItem(key: KeychainKey.userAccount) + } catch { return nil } + } + + set(value) { + do { + try setKeychainItem(key: KeychainKey.userAccount, item: value) + } catch {} + } + } + + private struct KeychainKey { + public static let mnemonic = "mnemonic" + public static let creationTime = "creationtime" + public static let masterPubKey = "masterpubkey" + public static let spendLimit = "spendlimit" + public static let pin = "pin" + public static let pinFailCount = "pinfailcount" + public static let pinFailTime = "pinfailheight" + public static let apiAuthKey = "authprivkey" + public static let userAccount = "https://api.breadwallet.com" + public static let seed = "seed" // deprecated + } + + private struct DefaultsKey { + public static let spendLimitAmount = "SPEND_LIMIT_AMOUNT" + public static let pinUnlockTime = "PIN_UNLOCK_TIME" + } + + private func signTx(_ tx: BRTxRef, forkId: Int = 0) -> Bool { + return autoreleasepool { + do { + var seed = UInt512() + defer { seed = UInt512() } + guard let wallet = wallet + else { + LWAnalytics.logEventWithParameters(itemName: ._20200111_WNI) + return false + } + guard let phrase: String = try keychainItem(key: KeychainKey.mnemonic) + else { + LWAnalytics.logEventWithParameters(itemName: ._20200111_PNI) + return false + } + + BRBIP39DeriveKey(&seed, phrase, nil) + return wallet.signTransaction(tx, forkId: forkId, seed: &seed) + } catch { + LWAnalytics.logEventWithParameters(itemName: ._20200111_UTST) + return false + } + } + } +} + +private func keychainItem(key: String) throws -> T? { + let query = [kSecClass as String: kSecClassGenericPassword as String, + kSecAttrService as String: WalletSecAttrService, + kSecAttrAccount as String: key, + kSecReturnData as String: true as Any] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == noErr || status == errSecItemNotFound + else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + guard let data = result as? Data else { return nil } + + switch T.self { + case is Data.Type: + return data as? T + case is String.Type: + return CFStringCreateFromExternalRepresentation(secureAllocator, data as CFData, + CFStringBuiltInEncodings.UTF8.rawValue) as? T + case is Int64.Type: + guard data.count == MemoryLayout.stride else { return nil } + return data.withUnsafeBytes { $0.pointee } + case is [AnyHashable: Any].Type: + return NSKeyedUnarchiver.unarchiveObject(with: data) as? T + default: + throw NSError(domain: NSOSStatusErrorDomain, code: Int(errSecParam)) + } +} + +private func setKeychainItem(key: String, item: T?, authenticated: Bool = false) throws { + let accessible = authenticated ? kSecAttrAccessibleWhenUnlockedThisDeviceOnly as String + : kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String + let query = [kSecClass as String: kSecClassGenericPassword as String, + kSecAttrService as String: WalletSecAttrService, + kSecAttrAccount as String: key] + var status = noErr + var data: Data? + if let item = item { + switch T.self { + case is Data.Type: + data = item as? Data + case is String.Type: + data = CFStringCreateExternalRepresentation(secureAllocator, item as! CFString, + CFStringBuiltInEncodings.UTF8.rawValue, 0) as Data + case is Int64.Type: + data = CFDataCreateMutable(secureAllocator, MemoryLayout.stride) as Data + [item].withUnsafeBufferPointer { data?.append($0) } + case is [AnyHashable: Any].Type: + data = NSKeyedArchiver.archivedData(withRootObject: item) + default: + throw NSError(domain: NSOSStatusErrorDomain, code: Int(errSecParam)) + } + } + + if data == nil { // delete item + if SecItemCopyMatching(query as CFDictionary, nil) != errSecItemNotFound { + status = SecItemDelete(query as CFDictionary) + } + } else if SecItemCopyMatching(query as CFDictionary, nil) != errSecItemNotFound + { // update existing item + let update = [kSecAttrAccessible as String: accessible, + kSecValueData as String: data as Any] + status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + } else { // add new item + let item = [kSecClass as String: kSecClassGenericPassword as String, + kSecAttrService as String: WalletSecAttrService, + kSecAttrAccount as String: key, + kSecAttrAccessible as String: accessible, + kSecValueData as String: data as Any] + status = SecItemAdd(item as CFDictionary, nil) + } + + guard status == noErr + else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } +} diff --git a/litewallet/src/WalletManager.swift b/litewallet/src/WalletManager.swift new file mode 100644 index 000000000..71250d313 --- /dev/null +++ b/litewallet/src/WalletManager.swift @@ -0,0 +1,652 @@ +import BRCore +import Foundation +// import sqlite3 +import SQLite3 +import SystemConfiguration + +internal let SQLITE_STATIC = unsafeBitCast(0, to: sqlite3_destructor_type.self) +internal let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + +enum WalletManagerError: Error { + case sqliteError(errorCode: Int32, description: String) +} + +private func SafeSqlite3ColumnBlob(statement: OpaquePointer, iCol: Int32) -> UnsafePointer? { + guard let result = sqlite3_column_blob(statement, iCol) else { return nil } + return result.assumingMemoryBound(to: T.self) +} + +// A WalletManger instance manages a single wallet, and that wallet's individual connection to the litecoin network. +// After instantiating a WalletManager object, call myWalletManager.peerManager.connect() to begin syncing. + +class WalletManager: BRWalletListener, BRPeerManagerListener { + internal var didInitWallet = false + internal let dbPath: String + internal var db: OpaquePointer? + private var txEnt: Int32 = 0 + private var blockEnt: Int32 = 0 + private var peerEnt: Int32 = 0 + internal let store: Store + var masterPubKey = BRMasterPubKey() + var earliestKeyTime: TimeInterval = 0 + + var userPreferredfpRate: Double = FalsePositiveRates.semiPrivate.rawValue + + static let sharedInstance: WalletManager = { + var instance: WalletManager? + do { + instance = try WalletManager(store: Store(), dbPath: nil) + } catch { + NSLog("ERROR: Instance of WalletManager not initialized") + } + return instance! + }() + + var wallet: BRWallet? { + guard masterPubKey != BRMasterPubKey() else { return nil } + guard let wallet = lazyWallet + else { + // stored transactions don't match masterPubKey + #if !Debug + do { try FileManager.default.removeItem(atPath: dbPath) } catch {} + #endif + return nil + } + + didInitWallet = true + return wallet + } + + var apiClient: BRAPIClient? { + guard masterPubKey != BRMasterPubKey() else { return nil } + return lazyAPIClient + } + + var peerManager: BRPeerManager? { + guard wallet != nil else { return nil } + return lazyPeerManager + } + + // TODO: Pass the fpRate from User Preferences + internal lazy var lazyPeerManager: BRPeerManager? = { + if let wallet = self.wallet { + return BRPeerManager(wallet: wallet, earliestKeyTime: self.earliestKeyTime, blocks: self.loadBlocks(), peers: self.loadPeers(), listener: self, fpRate: FalsePositiveRates.semiPrivate.rawValue) + } else { + return nil + } + }() + + internal lazy var lazyWallet: BRWallet? = BRWallet(transactions: self.loadTransactions(), + masterPubKey: self.masterPubKey, + listener: self) + + private lazy var lazyAPIClient: BRAPIClient? = { + guard let wallet = self.wallet else { return nil } + return BRAPIClient(authenticator: self) + }() + + var wordList: [NSString]? { + guard let path = Bundle.main.path(forResource: "BIP39Words", ofType: "plist") else { return nil } + return NSArray(contentsOfFile: path) as? [NSString] + } + + lazy var allWordsLists: [[NSString]] = { + var array: [[NSString]] = [] + Bundle.main.localizations.forEach { lang in + if let path = Bundle.main.path(forResource: "BIP39Words", ofType: "plist", inDirectory: nil, forLocalization: lang) + { + if let words = NSArray(contentsOfFile: path) as? [NSString] { + array.append(words) + } + } + } + return array + }() + + lazy var allWords: Set = { + var set: Set = Set() + + Bundle.main.localizations.forEach { lang in + if let path = Bundle.main.path(forResource: "BIP39Words", ofType: "plist", inDirectory: nil, forLocalization: lang) + { + if let words = NSArray(contentsOfFile: path) as? [NSString] { + set.formUnion(words.map { $0 as String }) + } + } + } + return set + }() + + var rawWordList: [UnsafePointer?]? { + guard let wordList = wordList, wordList.count == 2048 else { return nil } + return wordList.map { $0.utf8String } + } + + init(masterPubKey: BRMasterPubKey, + earliestKeyTime: TimeInterval, + dbPath: String? = nil, + store: Store, + fpRate: Double) throws + { + self.masterPubKey = masterPubKey + self.earliestKeyTime = earliestKeyTime + self.dbPath = try dbPath ?? + FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, + create: false).appendingPathComponent("BreadWallet.sqlite").path + self.store = store + userPreferredfpRate = fpRate + + // open sqlite database + if sqlite3_open_v2(self.dbPath, &db, + SQLITE_OPEN_FULLMUTEX | SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE, nil) != SQLITE_OK + { + print(String(cString: sqlite3_errmsg(db))) + let properties: [String: String] = ["ERROR_MESSAGE": String(cString: sqlite3_errmsg(db)), "ERROR_CODE": String(describing: sqlite3_errcode(db))] + LWAnalytics.logEventWithParameters(itemName: ._20200112_ERR, properties: properties) + + #if DEBUG + throw WalletManagerError.sqliteError(errorCode: sqlite3_errcode(db), + description: String(cString: sqlite3_errmsg(db))) + #else + try FileManager.default.removeItem(atPath: self.dbPath) + + if sqlite3_open_v2(self.dbPath, &db, + SQLITE_OPEN_FULLMUTEX | SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE, nil) != SQLITE_OK + { + throw WalletManagerError.sqliteError(errorCode: sqlite3_errcode(db), + description: String(cString: sqlite3_errmsg(db))) + } + #endif + } + + // create tables and indexes (these are inherited from CoreData) + + // tx table + sqlite3_exec(db, "create table if not exists ZBRTXMETADATAENTITY (" + + "Z_PK integer primary key," + + "Z_ENT integer," + + "Z_OPT integer," + + "ZTYPE integer," + + "ZBLOB blob," + + "ZTXHASH blob)", nil, nil, nil) + sqlite3_exec(db, "create index if not exists ZBRTXMETADATAENTITY_ZTXHASH_INDEX " + + "on ZBRTXMETADATAENTITY (ZTXHASH)", nil, nil, nil) + sqlite3_exec(db, "create index if not exists ZBRTXMETADATAENTITY_ZTYPE_INDEX " + + "on ZBRTXMETADATAENTITY (ZTYPE)", nil, nil, nil) + if sqlite3_errcode(db) != SQLITE_OK { print(String(cString: sqlite3_errmsg(db))) } + + // blocks table + sqlite3_exec(db, "create table if not exists ZBRMERKLEBLOCKENTITY (" + + "Z_PK integer primary key," + + "Z_ENT integer," + + "Z_OPT integer," + + "ZHEIGHT integer," + + "ZNONCE integer," + + "ZTARGET integer," + + "ZTOTALTRANSACTIONS integer," + + "ZVERSION integer," + + "ZTIMESTAMP timestamp," + + "ZBLOCKHASH blob," + + "ZFLAGS blob," + + "ZHASHES blob," + + "ZMERKLEROOT blob," + + "ZPREVBLOCK blob)", nil, nil, nil) + sqlite3_exec(db, "create index if not exists ZBRMERKLEBLOCKENTITY_ZBLOCKHASH_INDEX " + + "on ZBRMERKLEBLOCKENTITY (ZBLOCKHASH)", nil, nil, nil) + sqlite3_exec(db, "create index if not exists ZBRMERKLEBLOCKENTITY_ZHEIGHT_INDEX " + + "on ZBRMERKLEBLOCKENTITY (ZHEIGHT)", nil, nil, nil) + sqlite3_exec(db, "create index if not exists ZBRMERKLEBLOCKENTITY_ZPREVBLOCK_INDEX " + + "on ZBRMERKLEBLOCKENTITY (ZPREVBLOCK)", nil, nil, nil) + if sqlite3_errcode(db) != SQLITE_OK { print(String(cString: sqlite3_errmsg(db))) } + + // peers table + sqlite3_exec(db, "create table if not exists ZBRPEERENTITY (" + + "Z_PK integer PRIMARY KEY," + + "Z_ENT integer," + + "Z_OPT integer," + + "ZADDRESS integer," + + "ZMISBEHAVIN integer," + + "ZPORT integer," + + "ZSERVICES integer," + + "ZTIMESTAMP timestamp)", nil, nil, nil) + sqlite3_exec(db, "create index if not exists ZBRPEERENTITY_ZADDRESS_INDEX on ZBRPEERENTITY (ZADDRESS)", + nil, nil, nil) + sqlite3_exec(db, "create index if not exists ZBRPEERENTITY_ZMISBEHAVIN_INDEX on ZBRPEERENTITY (ZMISBEHAVIN)", + nil, nil, nil) + sqlite3_exec(db, "create index if not exists ZBRPEERENTITY_ZPORT_INDEX on ZBRPEERENTITY (ZPORT)", + nil, nil, nil) + sqlite3_exec(db, "create index if not exists ZBRPEERENTITY_ZTIMESTAMP_INDEX on ZBRPEERENTITY (ZTIMESTAMP)", + nil, nil, nil) + if sqlite3_errcode(db) != SQLITE_OK { print(String(cString: sqlite3_errmsg(db))) } + + // primary keys + sqlite3_exec(db, "create table if not exists Z_PRIMARYKEY (" + + "Z_ENT INTEGER PRIMARY KEY," + + "Z_NAME VARCHAR," + + "Z_SUPER INTEGER," + + "Z_MAX INTEGER)", nil, nil, nil) + sqlite3_exec(db, "insert into Z_PRIMARYKEY (Z_ENT, Z_NAME, Z_SUPER, Z_MAX) " + + "select 6, 'BRTxMetadataEntity', 0, 0 except " + + "select 6, Z_NAME, 0, 0 from Z_PRIMARYKEY where Z_NAME = 'BRTxMetadataEntity'", nil, nil, nil) + sqlite3_exec(db, "insert into Z_PRIMARYKEY (Z_ENT, Z_NAME, Z_SUPER, Z_MAX) " + + "select 2, 'BRMerkleBlockEntity', 0, 0 except " + + "select 2, Z_NAME, 0, 0 from Z_PRIMARYKEY where Z_NAME = 'BRMerkleBlockEntity'", nil, nil, nil) + sqlite3_exec(db, "insert into Z_PRIMARYKEY (Z_ENT, Z_NAME, Z_SUPER, Z_MAX) " + + "select 3, 'BRPeerEntity', 0, 0 except " + + "select 3, Z_NAME, 0, 0 from Z_PRIMARYKEY where Z_NAME = 'BRPeerEntity'", nil, nil, nil) + if sqlite3_errcode(db) != SQLITE_OK { print(String(cString: sqlite3_errmsg(db))) } + + var sql: OpaquePointer? + sqlite3_prepare_v2(db, "select Z_ENT, Z_NAME from Z_PRIMARYKEY", -1, &sql, nil) + defer { sqlite3_finalize(sql) } + + while sqlite3_step(sql) == SQLITE_ROW { + let name = String(cString: sqlite3_column_text(sql, 1)) + if name == "BRTxMetadataEntity" { txEnt = sqlite3_column_int(sql, 0) } + else if name == "BRMerkleBlockEntity" { blockEnt = sqlite3_column_int(sql, 0) } + else if name == "BRPeerEntity" { peerEnt = sqlite3_column_int(sql, 0) } + } + + if sqlite3_errcode(db) != SQLITE_DONE { print(String(cString: sqlite3_errmsg(db))) } + } + + func balanceChanged(_ balance: UInt64) { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .walletBalanceChangedNotification, object: nil, + userInfo: ["balance": balance]) + } + } + + func txAdded(_ tx: BRTxRef) { + DispatchQueue.walletQueue.async { + var buf = [UInt8](repeating: 0, count: BRTransactionSerialize(tx, nil, 0)) + let timestamp = (tx.pointee.timestamp > UInt32(NSTimeIntervalSince1970)) ? tx.pointee.timestamp - UInt32(NSTimeIntervalSince1970) : 0 + guard BRTransactionSerialize(tx, &buf, buf.count) == buf.count else { return } + [tx.pointee.blockHeight.littleEndian, timestamp.littleEndian].withUnsafeBytes { buf.append(contentsOf: $0) } + sqlite3_exec(self.db, "begin exclusive", nil, nil, nil) + + var sql: OpaquePointer? + sqlite3_prepare_v2(self.db, "select Z_MAX from Z_PRIMARYKEY where Z_ENT = \(self.txEnt)", -1, &sql, nil) + defer { sqlite3_finalize(sql) } + + guard sqlite3_step(sql) == SQLITE_ROW + else { + print(String(cString: sqlite3_errmsg(self.db))) + sqlite3_exec(self.db, "rollback", nil, nil, nil) + return + } + + let pk = sqlite3_column_int(sql, 0) + var sql2: OpaquePointer? + sqlite3_prepare_v2(self.db, "insert or rollback into ZBRTXMETADATAENTITY " + + "(Z_PK, Z_ENT, Z_OPT, ZTYPE, ZBLOB, ZTXHASH) " + + "values (\(pk + 1), \(self.txEnt), 1, 1, ?, ?)", -1, &sql2, nil) + defer { sqlite3_finalize(sql2) } + sqlite3_bind_blob(sql2, 1, buf, Int32(buf.count), SQLITE_TRANSIENT) + sqlite3_bind_blob(sql2, 2, [tx.pointee.txHash], Int32(MemoryLayout.size), SQLITE_TRANSIENT) + + guard sqlite3_step(sql2) == SQLITE_DONE + else { + print(String(cString: sqlite3_errmsg(self.db))) + return + } + + sqlite3_exec(self.db, "update or rollback Z_PRIMARYKEY set Z_MAX = \(pk + 1) " + + "where Z_ENT = \(self.txEnt) and Z_MAX = \(pk)", nil, nil, nil) + + guard sqlite3_errcode(self.db) == SQLITE_OK + else { + print(String(cString: sqlite3_errmsg(self.db))) + let properties: [String: String] = ["ERROR_MESSAGE": String(cString: sqlite3_errmsg(self.db)), "ERROR_CODE": String(describing: sqlite3_errcode(self.db))] + LWAnalytics.logEventWithParameters(itemName: ._20200112_ERR, properties: properties) + return + } + + sqlite3_exec(self.db, "commit", nil, nil, nil) + } + } + + func txUpdated(_ txHashes: [UInt256], blockHeight: UInt32, timestamp: UInt32) { + DispatchQueue.walletQueue.async { + guard !txHashes.isEmpty else { return } + let timestamp = (timestamp > UInt32(NSTimeIntervalSince1970)) ? timestamp - UInt32(NSTimeIntervalSince1970) : 0 + var sql: OpaquePointer?, sql2: OpaquePointer? + sqlite3_prepare_v2(self.db, "select ZTXHASH, ZBLOB from ZBRTXMETADATAENTITY where ZTYPE = 1 and " + + "ZTXHASH in (" + String(repeating: "?, ", count: txHashes.count - 1) + "?)", -1, &sql, nil) + defer { sqlite3_finalize(sql) } + + for i in 0 ..< txHashes.count { + sqlite3_bind_blob(sql, Int32(i + 1), UnsafePointer(txHashes) + i, Int32(MemoryLayout.size), + SQLITE_TRANSIENT) + } + + sqlite3_prepare_v2(self.db, "update ZBRTXMETADATAENTITY set ZBLOB = ? where ZTXHASH = ?", -1, &sql2, nil) + defer { sqlite3_finalize(sql2) } + + while sqlite3_step(sql) == SQLITE_ROW { + let hash = sqlite3_column_blob(sql, 0) + let buf = sqlite3_column_blob(sql, 1).assumingMemoryBound(to: UInt8.self) + var blob = [UInt8](UnsafeBufferPointer(start: buf, count: Int(sqlite3_column_bytes(sql, 1)))) + + [blockHeight.littleEndian, timestamp.littleEndian].withUnsafeBytes { + if blob.count > $0.count { + blob.replaceSubrange(blob.count - $0.count ..< blob.count, with: $0) + sqlite3_bind_blob(sql2, 1, blob, Int32(blob.count), SQLITE_TRANSIENT) + sqlite3_bind_blob(sql2, 2, hash, Int32(MemoryLayout.size), SQLITE_TRANSIENT) + sqlite3_step(sql2) + sqlite3_reset(sql2) + } + } + } + + if sqlite3_errcode(self.db) != SQLITE_DONE { print(String(cString: sqlite3_errmsg(self.db))) } + } + } + + func txDeleted(_ txHash: UInt256, notifyUser: Bool, recommendRescan: Bool) { + DispatchQueue.walletQueue.async { + var sql: OpaquePointer? + sqlite3_prepare_v2(self.db, "delete from ZBRTXMETADATAENTITY where ZTYPE = 1 and ZTXHASH = ?", -1, &sql, nil) + defer { sqlite3_finalize(sql) } + sqlite3_bind_blob(sql, 1, [txHash], Int32(MemoryLayout.size), SQLITE_TRANSIENT) + + guard sqlite3_step(sql) == SQLITE_DONE + else { + print(String(cString: sqlite3_errmsg(self.db))) + return + } + + if notifyUser { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .walletTxRejectedNotification, object: nil, + userInfo: ["txHash": txHash, "recommendRescan": recommendRescan]) + } + } + } + } + + func syncStarted() { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .walletSyncStartedNotification, object: nil) + } + } + + func syncStopped(_ error: BRPeerManagerError?) { + switch error { + case .some(let .posixError(errorCode, description)): + DispatchQueue.main.async { + NotificationCenter.default.post(name: .walletSyncStoppedNotification, object: nil, + userInfo: ["errorCode": errorCode, + "errorDescription": description]) + } + case .none: + DispatchQueue.main.async { + NotificationCenter.default.post(name: .walletSyncStoppedNotification, object: nil) + } + } + } + + func txStatusUpdate() { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .walletTxStatusUpdateNotification, object: nil) + } + } + + func saveBlocks(_ replace: Bool, _ blocks: [BRBlockRef?]) { + DispatchQueue.walletQueue.async { + var pk: Int32 = 0 + sqlite3_exec(self.db, "begin exclusive", nil, nil, nil) + + if replace { // delete existing blocks and replace + sqlite3_exec(self.db, "delete from ZBRMERKLEBLOCKENTITY", nil, nil, nil) + } else { // add to existing blocks + var sql: OpaquePointer? + sqlite3_prepare_v2(self.db, "select Z_MAX from Z_PRIMARYKEY where Z_ENT = \(self.blockEnt)", -1, &sql, nil) + defer { sqlite3_finalize(sql) } + + guard sqlite3_step(sql) == SQLITE_ROW + else { + print(String(cString: sqlite3_errmsg(self.db))) + + sqlite3_exec(self.db, "rollback", nil, nil, nil) + return + } + + pk = sqlite3_column_int(sql, 0) // get last primary key + } + + var sql2: OpaquePointer? + sqlite3_prepare_v2(self.db, "insert or rollback into ZBRMERKLEBLOCKENTITY (Z_PK, Z_ENT, Z_OPT, ZHEIGHT, " + + "ZNONCE, ZTARGET, ZTOTALTRANSACTIONS, ZVERSION, ZTIMESTAMP, ZBLOCKHASH, ZFLAGS, ZHASHES, " + + "ZMERKLEROOT, ZPREVBLOCK) values (?, \(self.blockEnt), 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", -1, &sql2, nil) + defer { sqlite3_finalize(sql2) } + + for b in blocks { + guard let b = b + else { + sqlite3_exec(self.db, "rollback", nil, nil, nil) + return + } + + let timestampResult = Int32(bitPattern: b.pointee.timestamp).subtractingReportingOverflow(Int32(NSTimeIntervalSince1970)) + guard !timestampResult.1 else { print("skipped block with overflowed timestamp"); continue } + + pk = pk + 1 + sqlite3_bind_int(sql2, 1, pk) + sqlite3_bind_int(sql2, 2, Int32(bitPattern: b.pointee.height)) + sqlite3_bind_int(sql2, 3, Int32(bitPattern: b.pointee.nonce)) + sqlite3_bind_int(sql2, 4, Int32(bitPattern: b.pointee.target)) + sqlite3_bind_int(sql2, 5, Int32(bitPattern: b.pointee.totalTx)) + sqlite3_bind_int(sql2, 6, Int32(bitPattern: b.pointee.version)) + sqlite3_bind_int(sql2, 7, timestampResult.0) + sqlite3_bind_blob(sql2, 8, [b.pointee.blockHash], Int32(MemoryLayout.size), SQLITE_TRANSIENT) + sqlite3_bind_blob(sql2, 9, [b.pointee.flags], Int32(b.pointee.flagsLen), SQLITE_TRANSIENT) + sqlite3_bind_blob(sql2, 10, [b.pointee.hashes], Int32(MemoryLayout.size * b.pointee.hashesCount), + SQLITE_TRANSIENT) + sqlite3_bind_blob(sql2, 11, [b.pointee.merkleRoot], Int32(MemoryLayout.size), SQLITE_TRANSIENT) + sqlite3_bind_blob(sql2, 12, [b.pointee.prevBlock], Int32(MemoryLayout.size), SQLITE_TRANSIENT) + + guard sqlite3_step(sql2) == SQLITE_DONE + else { + print(String(cString: sqlite3_errmsg(self.db))) + return + } + + sqlite3_reset(sql2) + } + + sqlite3_exec(self.db, "update or rollback Z_PRIMARYKEY set Z_MAX = \(pk) where Z_ENT = \(self.blockEnt)", + nil, nil, nil) + + guard sqlite3_errcode(self.db) == SQLITE_OK + else { + print(String(cString: sqlite3_errmsg(self.db))) + return + } + + sqlite3_exec(self.db, "commit", nil, nil, nil) + } + } + + func savePeers(_ replace: Bool, _ peers: [BRPeer]) { + DispatchQueue.walletQueue.async { + var pk: Int32 = 0 + sqlite3_exec(self.db, "begin exclusive", nil, nil, nil) + + if replace { // delete existing peers and replace + sqlite3_exec(self.db, "delete from ZBRPEERENTITY", nil, nil, nil) + } else { // add to existing peers + var sql: OpaquePointer? + sqlite3_prepare_v2(self.db, "select Z_MAX from Z_PRIMARYKEY where Z_ENT = \(self.peerEnt)", -1, &sql, nil) + defer { sqlite3_finalize(sql) } + + guard sqlite3_step(sql) == SQLITE_ROW + else { + print(String(cString: sqlite3_errmsg(self.db))) + sqlite3_exec(self.db, "rollback", nil, nil, nil) + return + } + + pk = sqlite3_column_int(sql, 0) // get last primary key + } + + var sql2: OpaquePointer? + sqlite3_prepare_v2(self.db, "insert or rollback into ZBRPEERENTITY " + + "(Z_PK, Z_ENT, Z_OPT, ZADDRESS, ZMISBEHAVIN, ZPORT, ZSERVICES, ZTIMESTAMP) " + + "values (?, \(self.peerEnt), 1, ?, 0, ?, ?, ?)", -1, &sql2, nil) + defer { sqlite3_finalize(sql2) } + + for p in peers { + pk = pk + 1 + sqlite3_bind_int(sql2, 1, pk) + sqlite3_bind_int(sql2, 2, Int32(bitPattern: p.address.u32.3.bigEndian)) + sqlite3_bind_int(sql2, 3, Int32(p.port)) + sqlite3_bind_int64(sql2, 4, Int64(bitPattern: p.services)) + sqlite3_bind_int64(sql2, 5, Int64(bitPattern: p.timestamp) - Int64(NSTimeIntervalSince1970)) + + guard sqlite3_step(sql2) == SQLITE_DONE + else { + print(String(cString: sqlite3_errmsg(self.db))) + return + } + + sqlite3_reset(sql2) + } + + sqlite3_exec(self.db, "update or rollback Z_PRIMARYKEY set Z_MAX = \(pk) where Z_ENT = \(self.peerEnt)", + nil, nil, nil) + + guard sqlite3_errcode(self.db) == SQLITE_OK + else { + print(String(cString: sqlite3_errmsg(self.db))) + return + } + + sqlite3_exec(self.db, "commit", nil, nil, nil) + } + } + + func networkIsReachable() -> Bool { + var flags: SCNetworkReachabilityFlags = [] + var zeroAddress = sockaddr() + zeroAddress.sa_len = UInt8(MemoryLayout.size) + zeroAddress.sa_family = sa_family_t(AF_INET) + guard let reachability = SCNetworkReachabilityCreateWithAddress(nil, &zeroAddress) else { return false } + if !SCNetworkReachabilityGetFlags(reachability, &flags) { return false } + return flags.contains(.reachable) && !flags.contains(.connectionRequired) + } + + private func loadTransactions() -> [BRTxRef?] { + DispatchQueue.main.async { self.store.perform(action: LoadTransactions.set(true)) } + var transactions = [BRTxRef?]() + var sql: OpaquePointer? + sqlite3_prepare_v2(db, "select ZBLOB from ZBRTXMETADATAENTITY where ZTYPE = 1", -1, &sql, nil) + defer { sqlite3_finalize(sql) } + + while sqlite3_step(sql) == SQLITE_ROW { + let len = Int(sqlite3_column_bytes(sql, 0)) + let buf = sqlite3_column_blob(sql, 0).assumingMemoryBound(to: UInt8.self) + guard len >= MemoryLayout.size * 2 else { return transactions } + var off = len - MemoryLayout.size * 2 + guard let tx = BRTransactionParse(buf, off) else { return transactions } + tx.pointee.blockHeight = + UnsafeRawPointer(buf).advanced(by: off).assumingMemoryBound(to: UInt32.self).pointee.littleEndian + off = off + MemoryLayout.size + let timestamp = UnsafeRawPointer(buf).advanced(by: off).assumingMemoryBound(to: UInt32.self).pointee.littleEndian + tx.pointee.timestamp = (timestamp == 0) ? timestamp : timestamp + UInt32(NSTimeIntervalSince1970) + transactions.append(tx) + } + + if sqlite3_errcode(db) != SQLITE_DONE { print(String(cString: sqlite3_errmsg(db))) } + DispatchQueue.main.async { self.store.perform(action: LoadTransactions.set(false)) } + return transactions + } + + private func loadBlocks() -> [BRBlockRef?] { + var blocks = [BRBlockRef?]() + var sql: OpaquePointer? + sqlite3_prepare_v2(db, "select ZHEIGHT, ZNONCE, ZTARGET, ZTOTALTRANSACTIONS, ZVERSION, ZTIMESTAMP, " + + "ZBLOCKHASH, ZFLAGS, ZHASHES, ZMERKLEROOT, ZPREVBLOCK from ZBRMERKLEBLOCKENTITY", -1, &sql, nil) + defer { sqlite3_finalize(sql) } + + while sqlite3_step(sql) == SQLITE_ROW { + guard let b = BRMerkleBlockNew() else { return blocks } + let maxTime: UInt32 = 0xC5B0_3780 + b.pointee.height = UInt32(bitPattern: sqlite3_column_int(sql, 0)) + b.pointee.nonce = UInt32(bitPattern: sqlite3_column_int(sql, 1)) + b.pointee.target = UInt32(bitPattern: sqlite3_column_int(sql, 2)) + b.pointee.totalTx = UInt32(bitPattern: sqlite3_column_int(sql, 3)) + b.pointee.version = UInt32(bitPattern: sqlite3_column_int(sql, 4)) + if UInt32(bitPattern: sqlite3_column_int(sql, 5)) >= maxTime { + b.pointee.timestamp = UInt32(NSTimeIntervalSince1970) + } else { + b.pointee.timestamp = UInt32(bitPattern: sqlite3_column_int(sql, 5)) + UInt32(NSTimeIntervalSince1970) + } + b.pointee.blockHash = sqlite3_column_blob(sql, 6).assumingMemoryBound(to: UInt256.self).pointee + + let flags: UnsafePointer? = SafeSqlite3ColumnBlob(statement: sql!, iCol: 7) + let flagsLen = Int(sqlite3_column_bytes(sql, 7)) + let hashes: UnsafePointer? = SafeSqlite3ColumnBlob(statement: sql!, iCol: 8) + let hashesCount = Int(sqlite3_column_bytes(sql, 8)) / MemoryLayout.size + BRMerkleBlockSetTxHashes(b, hashes, hashesCount, flags, flagsLen) + b.pointee.merkleRoot = sqlite3_column_blob(sql, 9).assumingMemoryBound(to: UInt256.self).pointee + b.pointee.prevBlock = sqlite3_column_blob(sql, 10).assumingMemoryBound(to: UInt256.self).pointee + blocks.append(b) + } + + if sqlite3_errcode(db) != SQLITE_DONE { print(String(cString: sqlite3_errmsg(db))) } + return blocks + } + + private func loadPeers() -> [BRPeer] { + var peers = [BRPeer]() + var sql: OpaquePointer? + sqlite3_prepare_v2(db, "select ZADDRESS, ZPORT, ZSERVICES, ZTIMESTAMP from ZBRPEERENTITY", -1, &sql, nil) + defer { sqlite3_finalize(sql) } + + while sqlite3_step(sql) == SQLITE_ROW { + var p = BRPeer() + p.address = UInt128(u32: (0, 0, UInt32(0xFFFF).bigEndian, + UInt32(bitPattern: sqlite3_column_int(sql, 0)).bigEndian)) + p.port = UInt16(truncatingIfNeeded: sqlite3_column_int(sql, 1)) + p.services = UInt64(bitPattern: sqlite3_column_int64(sql, 2)) + + let result = UInt64(bitPattern: sqlite3_column_int64(sql, 3)).addingReportingOverflow(UInt64(NSTimeIntervalSince1970)) + if result.1 { + print("skipped overflowed timestamp: \(sqlite3_column_int64(sql, 3))") + continue + } else { + p.timestamp = result.0 + peers.append(p) + } + } + + if sqlite3_errcode(db) != SQLITE_DONE { print(String(cString: sqlite3_errmsg(db))) } + return peers + } + + func isPhraseValid(_ phrase: String) -> Bool { + for wordList in allWordsLists { + var words = wordList.map { $0.utf8String } + guard let nfkdPhrase = CFStringCreateMutableCopy(secureAllocator, 0, phrase as CFString) else { return false } + CFStringNormalize(nfkdPhrase, .KD) + if BRBIP39PhraseIsValid(&words, nfkdPhrase as String) != 0 { + return true + } + } + return false + } + + func isWordValid(_ word: String) -> Bool { + return allWords.contains(word) + } + + var isWatchOnly: Bool { + let mpkData = Data(masterPubKey: masterPubKey) + return mpkData.isEmpty + } + + deinit { + if db != nil { sqlite3_close(db) } + } +} diff --git a/litewallet/tr.lproj/Localizable.strings b/litewallet/tr.lproj/Localizable.strings index ab4959018..d346bb581 100644 --- a/litewallet/tr.lproj/Localizable.strings +++ b/litewallet/tr.lproj/Localizable.strings @@ -904,9 +904,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Bir Litecoin adresi girin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Ağ Ücreti:%1$@"; - /* Fees: $0.01*/ "Send.fee" = "Ücretler: %1$@"; diff --git a/litewallet/uk.lproj/Localizable.strings b/litewallet/uk.lproj/Localizable.strings index eb28ad0d2..25cf423fe 100644 --- a/litewallet/uk.lproj/Localizable.strings +++ b/litewallet/uk.lproj/Localizable.strings @@ -904,9 +904,6 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "Введіть адресу Litecoin"; -/* Network Fee: $0.01 */ -"Send.fee" = "Плата за мережу: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "Збори: %1$@"; diff --git a/litewallet/zh-Hans.lproj/Localizable.strings b/litewallet/zh-Hans.lproj/Localizable.strings index bb5e54245..06418412f 100755 --- a/litewallet/zh-Hans.lproj/Localizable.strings +++ b/litewallet/zh-Hans.lproj/Localizable.strings @@ -877,9 +877,6 @@ /* Fee: $0.01 */ "Send.bareFee" = "费用: %1$@"; -/* Fee: $0.01 */ -"Send.bareFee" = "费用: %1$@"; - /* Send Bar Item Title */ "Send.barItemTitle" = "发送"; @@ -904,21 +901,12 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "输入一个Litecoin地址"; -/* Network Fee: $0.01 */ -"Send.fee" = "网络费:%1$@"; - -/* Fees: $0.01*/ -"Send.fee" = "费用: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "费用: %1$@"; /* Fees Blank: */ "Send.feeBlank" = "费用:"; -/* Fees Blank: */ -"Send.feeBlank" = "费用:"; - /* Payee identity not certified alert title. */ "Send.identityNotCertified" = "收款人身份未经认证。"; diff --git a/litewallet/zh-Hant.lproj/Localizable.strings b/litewallet/zh-Hant.lproj/Localizable.strings index 3152a6631..82e8441a9 100755 --- a/litewallet/zh-Hant.lproj/Localizable.strings +++ b/litewallet/zh-Hant.lproj/Localizable.strings @@ -877,9 +877,6 @@ /* Fee: $0.01 */ "Send.bareFee" = "費用: %1$@"; -/* Fee: $0.01 */ -"Send.bareFee" = "費用: %1$@"; - /* Send Bar Item Title */ "Send.barItemTitle" = "發送"; @@ -904,21 +901,12 @@ /* Enter LTC Address */ "Send.enterLTCAddress" = "輸入萊特幣地址"; -/* Network Fee: $0.01 */ -"Send.fee" = "網路費:%1$@"; - -/* Fees: $0.01*/ -"Send.fee" = "費用: %1$@"; - /* Fees: $0.01*/ "Send.fee" = "費用: %1$@"; /* Fees Blank: */ "Send.feeBlank" = "費用:"; -/* Fees Blank: */ -"Send.feeBlank" = "費用:"; - /* Payee identity not certified alert title. */ "Send.identityNotCertified" = "受款人身分未經認證。"; @@ -991,9 +979,6 @@ /* UDSystemError */ "Send.UnstoppableDomains.udSystemError" = "系統查找問題。 [錯誤:%2$d]"; -/* UDSystemError */ -"Send.UnstoppableDomains.udSystemError" = "系統查找問題。 [錯誤:%2$d]"; - /* Adress already used alert message - first part */ "Send.UsedAddress.firstLine" = "萊特幣位址專供一次性使用。";