From ea548c20bfc4c766f3a3466510de31082c288d18 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 3 Dec 2024 11:20:51 -0600 Subject: [PATCH 01/15] Get preview for VerifyWords screen working --- ios/Cove/Cove.swift | 45 ++- .../Container/NewHotWalletContainer.swift | 2 +- .../HotWallet/VerifyWordsScreen.swift | 370 ++++++++++-------- rust/src/word_validator.rs | 19 +- 4 files changed, 272 insertions(+), 164 deletions(-) diff --git a/ios/Cove/Cove.swift b/ios/Cove/Cove.swift index 7dfd4eb0..82218d32 100644 --- a/ios/Cove/Cove.swift +++ b/ios/Cove/Cove.swift @@ -10230,7 +10230,7 @@ public func FfiConverterTypeWalletsTable_lower(_ value: WalletsTable) -> UnsafeM public protocol WordValidatorProtocol : AnyObject { - func groupedWords() -> [[GroupedWord]] + func groupedWords(groupsOf: UInt8) -> [[GroupedWord]] func invalidWordsString(enteredWords: [[String]]) -> String @@ -10288,11 +10288,21 @@ open class WordValidator: } +public static func preview(preview: Bool, numberOfWords: NumberOfBip39Words? = nil) -> WordValidator { + return try! FfiConverterTypeWordValidator.lift(try! rustCall() { + uniffi_cove_fn_constructor_wordvalidator_preview( + FfiConverterBool.lower(preview), + FfiConverterOptionTypeNumberOfBip39Words.lower(numberOfWords),$0 + ) +}) +} + -open func groupedWords() -> [[GroupedWord]] { +open func groupedWords(groupsOf: UInt8 = UInt8(12)) -> [[GroupedWord]] { return try! FfiConverterSequenceSequenceTypeGroupedWord.lift(try! rustCall() { - uniffi_cove_fn_method_wordvalidator_grouped_words(self.uniffiClonePointer(),$0 + uniffi_cove_fn_method_wordvalidator_grouped_words(self.uniffiClonePointer(), + FfiConverterUInt8.lower(groupsOf),$0 ) }) } @@ -20108,6 +20118,30 @@ fileprivate struct FfiConverterOptionTypeMessageInfo: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeNumberOfBip39Words: FfiConverterRustBuffer { + typealias SwiftType = NumberOfBip39Words? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeNumberOfBip39Words.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeNumberOfBip39Words.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -21740,7 +21774,7 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_walletstable_len() != 35149) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_wordvalidator_grouped_words() != 32035) { + if (uniffi_cove_checksum_method_wordvalidator_grouped_words() != 49274) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_wordvalidator_invalid_words_string() != 7159) { @@ -21902,6 +21936,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_constructor_wallet_previewnewwallet() != 56877) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_constructor_wordvalidator_preview() != 53831) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_deviceaccess_timezone() != 16696) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/Cove/Flows/NewWalletFlow/Container/NewHotWalletContainer.swift b/ios/Cove/Flows/NewWalletFlow/Container/NewHotWalletContainer.swift index 0b0c229c..25568d55 100644 --- a/ios/Cove/Flows/NewWalletFlow/Container/NewHotWalletContainer.swift +++ b/ios/Cove/Flows/NewWalletFlow/Container/NewHotWalletContainer.swift @@ -19,7 +19,7 @@ struct NewHotWalletContainer: View { case let .import(numberOfWords, importType): HotWalletImportScreen(numberOfWords: numberOfWords, importType: importType) case let .verifyWords(walletId): - VerifyWordsScreen(id: walletId) + VerifyWordsContainer(id: walletId) } } } diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift index 7a3c0b0f..664c89ab 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift @@ -7,12 +7,46 @@ import SwiftUI -struct VerifyWordsScreen: View { +// MARK: CONTAINER + +struct VerifyWordsContainer: View { + @Environment(MainViewModel.self) private var app let id: WalletId + @State private var model: WalletViewModel? = nil + @State private var validator: WordValidator? = nil + + func initOnAppear() { + do { + let model = try app.getWalletViewModel(id: id) + let validator = try model.rust.wordValidator() + + self.model = model + self.validator = validator + } catch { + Log.error("VerifyWords failed to initialize: \(error)") + } + } + + var body: some View { + if let model, let validator { + VerifyWordsScreen(model: model, validator: validator) + } else { + Text("Loading....") + .onAppear(perform: initOnAppear) + } + } +} + +// MARK: Screen + +struct VerifyWordsScreen: View { @Environment(\.navigate) private var navigate - @Environment(MainViewModel.self) private var appModel - @State var model: WalletViewModel? = nil + @Environment(MainViewModel.self) private var app + + // args + let model: WalletViewModel + let validator: WordValidator // private @State private var tabIndex: Int = 0 @@ -20,11 +54,10 @@ struct VerifyWordsScreen: View { @State private var invalidWords: String = "" @State private var focusField: Int? - @State private var validator: WordValidator? = nil - @State private var groupedWords: [[GroupedWord]] = [[]] - @State private var enteredWords: [[String]] = [[]] - @State private var textFields: [String] = [] - @State private var filteredSuggestions: [String] = [] + @State private var groupedWords: [[GroupedWord]] + @State private var enteredWords: [[String]] + @State private var textFields: [String] + @State private var filteredSuggestions: [String] @StateObject private var keyboardObserver = KeyboardObserver() @@ -36,19 +69,20 @@ struct VerifyWordsScreen: View { @State private var activeAlert: AlertType? - func initOnAppear() { - do { - let model = try WalletViewModel(id: id) - let validator = try model.rust.wordValidator() - let groupedWords = validator.groupedWords() + var id: WalletId { + model.walletMetadata.id + } - self.model = model - self.validator = validator - self.groupedWords = groupedWords - enteredWords = groupedWords.map { $0.map { _ in "" }} - } catch { - Log.error("VerifyWords failed to initialize: \(error)") - } + init(model: WalletViewModel, validator: WordValidator) { + self.model = model + self.validator = validator + + let groupedWords = validator.groupedWords() + + self.groupedWords = groupedWords + self.enteredWords = groupedWords.map { $0.map { _ in "" } } + self.textFields = [] + self.filteredSuggestions = [] } var keyboardIsShowing: Bool { @@ -60,11 +94,13 @@ struct VerifyWordsScreen: View { } var buttonIsDisabled: Bool { - !validator!.isValidWordGroup(groupNumber: UInt8(tabIndex), enteredWords: enteredWords[tabIndex]) + !validator.isValidWordGroup( + groupNumber: UInt8(tabIndex), enteredWords: enteredWords[tabIndex] + ) } var isAllWordsValid: Bool { - validator!.isAllWordsValid(enteredWords: enteredWords) + validator.isAllWordsValid(enteredWords: enteredWords) } var lastIndex: Int { @@ -82,19 +118,23 @@ struct VerifyWordsScreen: View { case .words: Alert( title: Text("See Secret Words?"), - message: Text("Whoever has your secret words has access to your bitcoin. Please keep these safe and don't show them to anyone else."), + message: Text( + "Whoever has your secret words has access to your bitcoin. Please keep these safe and don't show them to anyone else." + ), primaryButton: .destructive(Text("Yes, Show Me")) { - appModel.pushRoute(Route.secretWords(id)) + app.pushRoute(Route.secretWords(id)) }, secondaryButton: .cancel(Text("Cancel")) ) case .skip: Alert( title: Text("Skip verifying words?"), - message: Text("Are you sure you want to skip verifying words? Without having a back of these words, you could lose your bitcoin"), + message: Text( + "Are you sure you want to skip verifying words? Without having a back of these words, you could lose your bitcoin" + ), primaryButton: .destructive(Text("Yes, Verify Later")) { Log.debug("Skipping verification, going to wallet id: \(id)") - appModel.resetRoute(to: Route.selectedWallet(id)) + app.resetRoute(to: Route.selectedWallet(id)) }, secondaryButton: .cancel(Text("Cancel")) ) @@ -110,121 +150,122 @@ struct VerifyWordsScreen: View { do { try model.rust.markWalletAsVerified() - appModel.resetRoute(to: Route.selectedWallet(id)) + app.resetRoute(to: Route.selectedWallet(id)) } catch { Log.error("Error marking wallet as verified: \(error)") } } var body: some View { - if let model, let validator { - SunsetWave { - VStack { - Spacer() - - if !keyboardIsShowing { - Text("Please verify your words") - .font(.title2) - .fontWeight(.medium) - .foregroundColor(.white.opacity(0.85)) - .padding(.top, 60) - .padding(.bottom, 30) - } + SunsetWave { + VStack { + Spacer() + + if !keyboardIsShowing { + Text("Please verify your words") + .font(.title2) + .fontWeight(.medium) + .foregroundColor(.white.opacity(0.85)) + .padding(.top, 60) + .padding(.bottom, 30) + } - FixedGlassCard { - VStack { - TabView(selection: $tabIndex) { - ForEach(Array(validator.groupedWords().enumerated()), id: \.offset) { index, wordGroup in - VStack { - CardTab(wordGroup: wordGroup, fields: $enteredWords[index], filteredSuggestions: $filteredSuggestions, focusField: $focusField) - .tag(index) - .padding(.bottom, keyboardIsShowing ? 60 : 20) - } + FixedGlassCard { + VStack { + TabView(selection: $tabIndex) { + ForEach(Array(groupedWords.enumerated()), id: \.offset) { + index, wordGroup in + VStack { + CardTab( + wordGroup: wordGroup, fields: $enteredWords[index], + filteredSuggestions: $filteredSuggestions, + focusField: $focusField + ) + .tag(index) + .padding(.bottom, keyboardIsShowing ? 60 : 20) } - .padding(.horizontal, 30) } + .padding(.horizontal, 30) } } - .frame(height: cardHeight) - .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic)) - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - HStack { - ForEach(filteredSuggestions, id: \.self) { word in - Spacer() - Button(word) { - guard let focusField else { return } - let (outerIndex, remainder) = focusField.quotientAndRemainder(dividingBy: 6) - let innerIndex = remainder - 1 - enteredWords[outerIndex][innerIndex] = word - self.focusField = focusField + 1 - } - .foregroundColor(.secondary) - Spacer() - - // only show divider in the middle - if filteredSuggestions.count > 1, filteredSuggestions.last != word { - Divider() - } + } + .frame(height: cardHeight) + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic)) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + HStack { + ForEach(filteredSuggestions, id: \.self) { word in + Spacer() + Button(word) { + guard let focusField else { return } + let (outerIndex, remainder) = focusField.quotientAndRemainder( + dividingBy: 6) + let innerIndex = remainder - 1 + enteredWords[outerIndex][innerIndex] = word + self.focusField = focusField + 1 + } + .foregroundColor(.secondary) + Spacer() + + // only show divider in the middle + if filteredSuggestions.count > 1, filteredSuggestions.last != word { + Divider() } } } } - .padding(.horizontal, 30) + } + .padding(.horizontal, 30) - Spacer() + Spacer() - if tabIndex == lastIndex { - Button("Confirm") { - confirm(model, validator) - } - .buttonStyle(GradientButtonStyle(disabled: !isAllWordsValid)) - .padding(.top, 20) - - } else { - Button("Next") { - withAnimation { - tabIndex += 1 - } - } - .buttonStyle(GlassyButtonStyle(disabled: buttonIsDisabled)) - .disabled(buttonIsDisabled) - .foregroundStyle(Color.red) - .padding(.top, 20) + if tabIndex == lastIndex { + Button("Confirm") { + confirm(model, validator) } + .buttonStyle(GradientButtonStyle(disabled: !isAllWordsValid)) + .padding(.top, 20) - Button(action: { - activeAlert = .words - }) { - Text("View Words") - .font(.subheadline) - .fontWeight(.semibold) - .foregroundStyle(.opacity(0.8)) - } - .padding(.top, 10) - - Button(action: { - activeAlert = .skip - }) { - Text("SKIP") - .font(.subheadline) - .fontWeight(.semibold) - .foregroundStyle(.opacity(0.6)) + } else { + Button("Next") { + withAnimation { + tabIndex += 1 + } } - .padding(.top, 10) + .buttonStyle(GlassyButtonStyle(disabled: buttonIsDisabled)) + .disabled(buttonIsDisabled) + .foregroundStyle(Color.red) + .padding(.top, 20) + } - Spacer() + Button(action: { + activeAlert = .words + }) { + Text("View Words") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.opacity(0.8)) } + .padding(.top, 10) + + Button(action: { + activeAlert = .skip + }) { + Text("SKIP") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.opacity(0.6)) + } + .padding(.top, 10) + + Spacer() } - .alert(item: $activeAlert) { alertType in - DisplayAlert(for: alertType) - } - .onChange(of: focusField) { _, _ in - filteredSuggestions = [] - } - } else { - Text("Loading....") - .onAppear(perform: initOnAppear) + } + .alert(item: $activeAlert) { alertType in + DisplayAlert(for: alertType) + } + .onChange(of: focusField) { _, _ in + filteredSuggestions = [] } } } @@ -248,11 +289,13 @@ private struct CardTab: View { var body: some View { VStack(spacing: cardSpacing) { ForEach(Array(wordGroup.enumerated()), id: \.offset) { index, word in - AutocompleteField(autocomplete: Bip39AutoComplete(), - word: word, - text: $fields[index], - filteredSuggestions: $filteredSuggestions, - focusField: $focusField) + AutocompleteField( + autocomplete: Bip39AutoComplete(), + word: word, + text: $fields[index], + filteredSuggestions: $filteredSuggestions, + focusField: $focusField + ) } } } @@ -327,46 +370,63 @@ private struct AutocompleteField: View { } var textField: some View { - TextField("", text: $text, - prompt: Text("enter secret word...") - .foregroundColor(.white.opacity(0.65))) - .foregroundColor(borderColor ?? .white) - .frame(alignment: .trailing) - .padding(.trailing, 8) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .keyboardType(.asciiCapable) - .focused($isFocused) - .onChange(of: isFocused) { - if !isFocused { return showSuggestions = false } - - if isFocused { - focusField = Int(word.number) - } + TextField( + "", text: $text, + prompt: Text("enter secret word...") + .foregroundColor(.white.opacity(0.65)) + ) + .foregroundColor(borderColor ?? .white) + .frame(alignment: .trailing) + .padding(.trailing, 8) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.asciiCapable) + .focused($isFocused) + .onChange(of: isFocused) { + if !isFocused { + showSuggestions = false + return } - .onSubmit { - submitFocusField() + + if isFocused { + focusField = Int(word.number) } - .onChange(of: focusField) { _, fieldNumber in - guard let fieldNumber else { return } - if word.number == fieldNumber { - isFocused = true - } + } + .onSubmit { + submitFocusField() + } + .onChange(of: focusField) { _, fieldNumber in + guard let fieldNumber else { return } + if word.number == fieldNumber { + isFocused = true } - .onChange(of: text) { - filteredSuggestions = autocomplete.autocomplete(word: text) + } + .onChange(of: text) { + filteredSuggestions = autocomplete.autocomplete(word: text) - if filteredSuggestions.count == 1, filteredSuggestions.first == word.word { - text = filteredSuggestions.first! + if filteredSuggestions.count == 1, filteredSuggestions.first == word.word { + text = filteredSuggestions.first! - submitFocusField() - return - } + submitFocusField() + return } + } } } #Preview { - VerifyWordsScreen(id: WalletId()) - .environment(MainViewModel()) + struct Container: View { + @State var model = WalletViewModel(preview: "preview_only") + @State var validator = WordValidator.preview(preview: true) + + var body: some View { + VerifyWordsScreen(model: model, validator: validator) + .environment(MainViewModel()) + } + } + + return + AsyncPreview { + Container() + } } diff --git a/rust/src/word_validator.rs b/rust/src/word_validator.rs index 439b55bf..35e1f869 100644 --- a/rust/src/word_validator.rs +++ b/rust/src/word_validator.rs @@ -1,6 +1,6 @@ use bip39::Mnemonic; -use crate::{mnemonic::GroupedWord, mnemonic::WordAccess as _}; +use crate::mnemonic::{GroupedWord, NumberOfBip39Words, WordAccess as _}; #[derive(Debug, Clone, uniffi::Object)] pub struct WordValidator { @@ -16,9 +16,9 @@ impl WordValidator { #[uniffi::export] impl WordValidator { // get the grouped words - #[uniffi::method] - pub fn grouped_words(&self) -> Vec> { - self.mnemonic.grouped_words_of(6) + #[uniffi::method(default(groups_of = 12))] + pub fn grouped_words(&self, groups_of: u8) -> Vec> { + self.mnemonic.grouped_words_of(groups_of as usize) } // check if the word group passed in is valid @@ -65,4 +65,15 @@ impl WordValidator { invalid_words.join(", ") } + + // preview only + #[uniffi::constructor(name = "preview", default(number_of_words = None))] + pub fn preview(preview: bool, number_of_words: Option) -> Self { + assert!(preview); + + let number_of_words = number_of_words.unwrap_or(NumberOfBip39Words::Twelve); + let mnemonic = number_of_words.to_mnemonic().clone(); + + Self { mnemonic } + } } From 50a27b6ed072c352f14c63b9ef0dd8d4f38988f1 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 3 Dec 2024 17:12:29 -0600 Subject: [PATCH 02/15] Layout new verify words screen --- ios/Cove/Cove.swift | 27 ++ ios/Cove/FlowLayout.swift | 57 +++ .../HotWallet/HotWalletCreateScreen.swift | 3 + .../HotWallet/VerifyWordsScreen.swift | 350 +++--------------- rust/rustfmt.toml | 1 + rust/src/word_validator.rs | 56 ++- 6 files changed, 203 insertions(+), 291 deletions(-) create mode 100644 ios/Cove/FlowLayout.swift diff --git a/ios/Cove/Cove.swift b/ios/Cove/Cove.swift index 82218d32..f1329d23 100644 --- a/ios/Cove/Cove.swift +++ b/ios/Cove/Cove.swift @@ -10238,6 +10238,10 @@ public protocol WordValidatorProtocol : AnyObject { func isValidWordGroup(groupNumber: UInt8, enteredWords: [String]) -> Bool + func isWordCorrect(word: String, `for`: UInt8) -> Bool + + func possibleWords(`for`: UInt8) -> [String] + } open class WordValidator: @@ -10332,6 +10336,23 @@ open func isValidWordGroup(groupNumber: UInt8, enteredWords: [String]) -> Bool }) } +open func isWordCorrect(word: String, `for`: UInt8) -> Bool { + return try! FfiConverterBool.lift(try! rustCall() { + uniffi_cove_fn_method_wordvalidator_is_word_correct(self.uniffiClonePointer(), + FfiConverterString.lower(word), + FfiConverterUInt8.lower(`for`),$0 + ) +}) +} + +open func possibleWords(`for`: UInt8) -> [String] { + return try! FfiConverterSequenceString.lift(try! rustCall() { + uniffi_cove_fn_method_wordvalidator_possible_words(self.uniffiClonePointer(), + FfiConverterUInt8.lower(`for`),$0 + ) +}) +} + } @@ -21786,6 +21807,12 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_wordvalidator_is_valid_word_group() != 6393) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_wordvalidator_is_word_correct() != 39689) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_wordvalidator_possible_words() != 25098) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_constructor_address_from_string() != 47046) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/Cove/FlowLayout.swift b/ios/Cove/FlowLayout.swift new file mode 100644 index 00000000..a298a13a --- /dev/null +++ b/ios/Cove/FlowLayout.swift @@ -0,0 +1,57 @@ +// +// FlowLayout.swift +// Cove +// +// Created by Praveen Perera on 12/3/24. +// +import SwiftUI + +struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + var totalHeight: CGFloat = 0 + var totalWidth: CGFloat = 0 + var lineWidth: CGFloat = 0 + var lineHeight: CGFloat = 0 + + for size in sizes { + if lineWidth + size.width > proposal.width ?? 0 { + totalHeight += lineHeight + lineWidth = size.width + lineHeight = size.height + } else { + lineWidth += size.width + lineHeight = max(lineHeight, size.height) + totalWidth = max(totalWidth, lineWidth) + } + } + totalHeight += lineHeight + return CGSize(width: totalWidth, height: totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal _: ProposedViewSize, subviews: Subviews, cache _: inout ()) { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + var x = bounds.minX + var y = bounds.minY + var lineHeight: CGFloat = 0 + + for (index, subview) in subviews.enumerated() { + let size = sizes[index] + if x + size.width > bounds.maxX { + y += lineHeight + spacing + x = bounds.minX + lineHeight = 0 + } + + subview.place( + at: CGPoint(x: x, y: y), + proposal: .unspecified + ) + + x += size.width + spacing + lineHeight = max(lineHeight, size.height) + } + } +} diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift index 8c5e85ec..e1ff1d17 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift @@ -72,6 +72,9 @@ struct WordsView: View { .font(.subheadline) .multilineTextAlignment(.leading) .fontWeight(.bold) + .foregroundStyle(.white) + .opacity(0.9) + Spacer() } diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift index 664c89ab..074d84c4 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift @@ -49,21 +49,12 @@ struct VerifyWordsScreen: View { let validator: WordValidator // private - @State private var tabIndex: Int = 0 - - @State private var invalidWords: String = "" - @State private var focusField: Int? - - @State private var groupedWords: [[GroupedWord]] - @State private var enteredWords: [[String]] - @State private var textFields: [String] - @State private var filteredSuggestions: [String] - - @StateObject private var keyboardObserver = KeyboardObserver() + @State private var wordNumber: Int + @State private var possibleWords: [String] // alerts private enum AlertType: Identifiable { - case error, words, skip + case words, skip var id: Self { self } } @@ -76,45 +67,17 @@ struct VerifyWordsScreen: View { init(model: WalletViewModel, validator: WordValidator) { self.model = model self.validator = validator + wordNumber = 1 - let groupedWords = validator.groupedWords() - - self.groupedWords = groupedWords - self.enteredWords = groupedWords.map { $0.map { _ in "" } } - self.textFields = [] - self.filteredSuggestions = [] - } - - var keyboardIsShowing: Bool { - keyboardObserver.keyboardIsShowing - } - - var cardHeight: CGFloat { - keyboardIsShowing ? 325 : 425 + possibleWords = validator.possibleWords(for: 1) } var buttonIsDisabled: Bool { - !validator.isValidWordGroup( - groupNumber: UInt8(tabIndex), enteredWords: enteredWords[tabIndex] - ) - } - - var isAllWordsValid: Bool { - validator.isAllWordsValid(enteredWords: enteredWords) - } - - var lastIndex: Int { - groupedWords.count - 1 + true } private func DisplayAlert(for alertType: AlertType) -> Alert { switch alertType { - case .error: - Alert( - title: Text("Words not valid"), - message: Text("The following words are not valid: \(invalidWords)"), - dismissButton: .cancel(Text("OK")) - ) case .words: Alert( title: Text("See Secret Words?"), @@ -141,13 +104,7 @@ struct VerifyWordsScreen: View { } } - func confirm(_ model: WalletViewModel, _ validator: WordValidator) { - guard isAllWordsValid else { - activeAlert = .error - invalidWords = validator.invalidWordsString(enteredWords: enteredWords) - return - } - + func confirm(_ model: WalletViewModel, _: WordValidator) { do { try model.rust.markWalletAsVerified() app.resetRoute(to: Route.selectedWallet(id)) @@ -156,261 +113,76 @@ struct VerifyWordsScreen: View { } } - var body: some View { - SunsetWave { - VStack { - Spacer() - - if !keyboardIsShowing { - Text("Please verify your words") - .font(.title2) - .fontWeight(.medium) - .foregroundColor(.white.opacity(0.85)) - .padding(.top, 60) - .padding(.bottom, 30) - } + var columns: [GridItem] { + let item = GridItem(.adaptive(minimum: screenWidth * 0.25 - 20)) + return [item, item, item, item] + } - FixedGlassCard { - VStack { - TabView(selection: $tabIndex) { - ForEach(Array(groupedWords.enumerated()), id: \.offset) { - index, wordGroup in - VStack { - CardTab( - wordGroup: wordGroup, fields: $enteredWords[index], - filteredSuggestions: $filteredSuggestions, - focusField: $focusField - ) - .tag(index) - .padding(.bottom, keyboardIsShowing ? 60 : 20) - } - } - .padding(.horizontal, 30) - } + var body: some View { + VStack(spacing: 48) { + Spacer() + Text("What is word #\(wordNumber)?") + .foregroundStyle(.white) + .font(.title2) + .fontWeight(.semibold) + + Rectangle().frame(width: 200, height: 1) + .foregroundColor(.white) + + LazyVGrid(columns: columns, spacing: 20) { + ForEach(Array(possibleWords.enumerated()), id: \.offset) { _, word in + Button(action: {}) { + Text(word) + .font(.caption) + .foregroundStyle(.midnightBlue.opacity(0.90)) + .multilineTextAlignment(.center) + .frame(alignment: .leading) + .minimumScaleFactor(0.90) + .lineLimit(1) } + .padding(.horizontal) + .padding(.vertical, 12) + .background(Color.btnPrimary) + .cornerRadius(10) } - .frame(height: cardHeight) - .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic)) - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - HStack { - ForEach(filteredSuggestions, id: \.self) { word in - Spacer() - Button(word) { - guard let focusField else { return } - let (outerIndex, remainder) = focusField.quotientAndRemainder( - dividingBy: 6) - let innerIndex = remainder - 1 - enteredWords[outerIndex][innerIndex] = word - self.focusField = focusField + 1 - } - .foregroundColor(.secondary) - Spacer() + } - // only show divider in the middle - if filteredSuggestions.count > 1, filteredSuggestions.last != word { - Divider() - } - } - } - } - } - .padding(.horizontal, 30) + Spacer() + HStack { + DotMenuView(selected: 3, size: 5) Spacer() + } - if tabIndex == lastIndex { - Button("Confirm") { - confirm(model, validator) - } - .buttonStyle(GradientButtonStyle(disabled: !isAllWordsValid)) - .padding(.top, 20) - - } else { - Button("Next") { - withAnimation { - tabIndex += 1 - } - } - .buttonStyle(GlassyButtonStyle(disabled: buttonIsDisabled)) - .disabled(buttonIsDisabled) - .foregroundStyle(Color.red) - .padding(.top, 20) - } - - Button(action: { - activeAlert = .words - }) { - Text("View Words") - .font(.subheadline) - .fontWeight(.semibold) - .foregroundStyle(.opacity(0.8)) - } - .padding(.top, 10) + VStack(spacing: 12) { + HStack { + Text("Verify your recovery words") + .font(.system(size: 38, weight: .semibold)) + .foregroundColor(.white) - Button(action: { - activeAlert = .skip - }) { - Text("SKIP") - .font(.subheadline) - .fontWeight(.semibold) - .foregroundStyle(.opacity(0.6)) + Spacer() } - .padding(.top, 10) - Spacer() + Text("Your secret recovery words are the only way to recover your wallet if you lose your phone or switch to a different wallet. Once you leave this screen, you won’t be able to view them again.") + .font(.subheadline) + .foregroundStyle(.lightGray) + .opacity(0.75) } } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) .alert(item: $activeAlert) { alertType in DisplayAlert(for: alertType) } - .onChange(of: focusField) { _, _ in - filteredSuggestions = [] - } - } -} - -private struct CardTab: View { - let wordGroup: [GroupedWord] - @Binding var fields: [String] - @Binding var filteredSuggestions: [String] - @Binding var focusField: Int? - - @StateObject private var keyboardObserver = KeyboardObserver() - - var keyboardIsShowing: Bool { - keyboardObserver.keyboardIsShowing - } - - var cardSpacing: CGFloat { - keyboardIsShowing ? 15 : 20 - } - - var body: some View { - VStack(spacing: cardSpacing) { - ForEach(Array(wordGroup.enumerated()), id: \.offset) { index, word in - AutocompleteField( - autocomplete: Bip39AutoComplete(), - word: word, - text: $fields[index], - filteredSuggestions: $filteredSuggestions, - focusField: $focusField - ) - } - } - } -} - -private struct AutocompleteField: View { - let autocomplete: Bip39AutoComplete - let word: GroupedWord - - @Binding var text: String - @Binding var filteredSuggestions: [String] - @Binding var focusField: Int? - - @State private var showSuggestions = false - @State private var offset: CGPoint = .zero - @FocusState private var isFocused: Bool - - var borderColor: Color? { - // starting state - if text == "" { - return .none - } - - // correct - if text.lowercased() == word.word { - return Color.green.opacity(0.8) - } - - // focused and not the only suggestion - if isFocused, filteredSuggestions.count > 1 { - return .none - } - - // focused, but no other possibilities left - if isFocused, filteredSuggestions.isEmpty { - return Color.red.opacity(0.8) - } - - // wrong word, not focused - if text.lowercased() != word.word { - return Color.red.opacity(0.8) - } - - return .none - } - - var body: some View { - HStack { - Text("\(String(format: "%02d", word.number)). ") - .foregroundColor(.secondary) - - textField - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .overlay( - Group { - if let color = borderColor { - RoundedRectangle(cornerRadius: 10) - .stroke(color, lineWidth: 2) - } - }) - } - - func submitFocusField() { - filteredSuggestions = [] - guard let focusField else { - return - } - - self.focusField = focusField + 1 - } - - var textField: some View { - TextField( - "", text: $text, - prompt: Text("enter secret word...") - .foregroundColor(.white.opacity(0.65)) + .background( + Image(.newWalletPattern) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: screenHeight * 0.75, alignment: .topTrailing) + .frame(maxWidth: .infinity) + .opacity(0.5) ) - .foregroundColor(borderColor ?? .white) - .frame(alignment: .trailing) - .padding(.trailing, 8) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .keyboardType(.asciiCapable) - .focused($isFocused) - .onChange(of: isFocused) { - if !isFocused { - showSuggestions = false - return - } - - if isFocused { - focusField = Int(word.number) - } - } - .onSubmit { - submitFocusField() - } - .onChange(of: focusField) { _, fieldNumber in - guard let fieldNumber else { return } - if word.number == fieldNumber { - isFocused = true - } - } - .onChange(of: text) { - filteredSuggestions = autocomplete.autocomplete(word: text) - - if filteredSuggestions.count == 1, filteredSuggestions.first == word.word { - text = filteredSuggestions.first! - - submitFocusField() - return - } - } + .background(Color.midnightBlue) } } diff --git a/rust/rustfmt.toml b/rust/rustfmt.toml index 0549a67a..281869c4 100644 --- a/rust/rustfmt.toml +++ b/rust/rustfmt.toml @@ -1 +1,2 @@ single_line_let_else_max_width = 80 +single_line_if_else_max_width = 50 diff --git a/rust/src/word_validator.rs b/rust/src/word_validator.rs index 35e1f869..b7106db9 100644 --- a/rust/src/word_validator.rs +++ b/rust/src/word_validator.rs @@ -1,20 +1,72 @@ use bip39::Mnemonic; +use rand::seq::SliceRandom; use crate::mnemonic::{GroupedWord, NumberOfBip39Words, WordAccess as _}; #[derive(Debug, Clone, uniffi::Object)] pub struct WordValidator { mnemonic: Mnemonic, + words: Vec<&'static str>, } impl WordValidator { pub fn new(mnemonic: Mnemonic) -> Self { - Self { mnemonic } + let words = mnemonic.words().collect(); + Self { mnemonic, words } } } #[uniffi::export] impl WordValidator { + // get a word list of possible words for the word number + #[uniffi::method] + pub fn possible_words(&self, for_: u8) -> Vec { + let Some(word_index) = for_.checked_sub(1) else { return vec![] }; + let word_index = word_index as usize; + if word_index > self.words.len() { + return vec![]; + } + + let mut rng = rand::thread_rng(); + let correct_word = self.words[word_index as usize]; + + let mut words_clone = self.words.clone(); + words_clone.shuffle(&mut rng); + + let new_words = NumberOfBip39Words::Twelve.to_mnemonic(); + + let five_existing_words = words_clone.iter().take(5).cloned(); + + let six_new_words = new_words.words().take(6); + let correct = std::iter::once(correct_word); + + let mut combined: Vec = five_existing_words + .chain(six_new_words) + .chain(correct) + .map(|word| word.to_string()) + .collect(); + + combined.shuffle(&mut rng); + + combined + } + + // check if the selected word is correct + #[uniffi::method] + pub fn is_word_correct(&self, word: String, for_: u8) -> bool { + let Some(word_index) = for_.checked_sub(1) else { return false }; + let word_index = word_index as usize; + if word_index > self.words.len() { + return false; + } + + let correct_word = self.words[word_index]; + correct_word == word + } + + // OLD API + // TODO: remove this if no longer used + // get the grouped words #[uniffi::method(default(groups_of = 12))] pub fn grouped_words(&self, groups_of: u8) -> Vec> { @@ -74,6 +126,6 @@ impl WordValidator { let number_of_words = number_of_words.unwrap_or(NumberOfBip39Words::Twelve); let mnemonic = number_of_words.to_mnemonic().clone(); - Self { mnemonic } + Self::new(mnemonic) } } From 7bebdb0811b41f826f1ac9666b69b730d1458589 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 4 Dec 2024 11:41:21 -0600 Subject: [PATCH 03/15] Complete interaction for verifying wallet --- ios/Cove/Cove.swift | 50 +---- .../HotWallet/VerifyWordsScreen.swift | 201 ++++++++++++++++-- ios/Cove/Views/SidebarView.swift | 6 +- rust/src/word_validator.rs | 81 +++---- 4 files changed, 220 insertions(+), 118 deletions(-) diff --git a/ios/Cove/Cove.swift b/ios/Cove/Cove.swift index f1329d23..5192cda5 100644 --- a/ios/Cove/Cove.swift +++ b/ios/Cove/Cove.swift @@ -10230,13 +10230,7 @@ public func FfiConverterTypeWalletsTable_lower(_ value: WalletsTable) -> UnsafeM public protocol WordValidatorProtocol : AnyObject { - func groupedWords(groupsOf: UInt8) -> [[GroupedWord]] - - func invalidWordsString(enteredWords: [[String]]) -> String - - func isAllWordsValid(enteredWords: [[String]]) -> Bool - - func isValidWordGroup(groupNumber: UInt8, enteredWords: [String]) -> Bool + func isComplete(wordNumber: UInt8) -> Bool func isWordCorrect(word: String, `for`: UInt8) -> Bool @@ -10303,35 +10297,10 @@ public static func preview(preview: Bool, numberOfWords: NumberOfBip39Words? = n -open func groupedWords(groupsOf: UInt8 = UInt8(12)) -> [[GroupedWord]] { - return try! FfiConverterSequenceSequenceTypeGroupedWord.lift(try! rustCall() { - uniffi_cove_fn_method_wordvalidator_grouped_words(self.uniffiClonePointer(), - FfiConverterUInt8.lower(groupsOf),$0 - ) -}) -} - -open func invalidWordsString(enteredWords: [[String]]) -> String { - return try! FfiConverterString.lift(try! rustCall() { - uniffi_cove_fn_method_wordvalidator_invalid_words_string(self.uniffiClonePointer(), - FfiConverterSequenceSequenceString.lower(enteredWords),$0 - ) -}) -} - -open func isAllWordsValid(enteredWords: [[String]]) -> Bool { - return try! FfiConverterBool.lift(try! rustCall() { - uniffi_cove_fn_method_wordvalidator_is_all_words_valid(self.uniffiClonePointer(), - FfiConverterSequenceSequenceString.lower(enteredWords),$0 - ) -}) -} - -open func isValidWordGroup(groupNumber: UInt8, enteredWords: [String]) -> Bool { +open func isComplete(wordNumber: UInt8) -> Bool { return try! FfiConverterBool.lift(try! rustCall() { - uniffi_cove_fn_method_wordvalidator_is_valid_word_group(self.uniffiClonePointer(), - FfiConverterUInt8.lower(groupNumber), - FfiConverterSequenceString.lower(enteredWords),$0 + uniffi_cove_fn_method_wordvalidator_is_complete(self.uniffiClonePointer(), + FfiConverterUInt8.lower(wordNumber),$0 ) }) } @@ -21795,16 +21764,7 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_walletstable_len() != 35149) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_wordvalidator_grouped_words() != 49274) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_cove_checksum_method_wordvalidator_invalid_words_string() != 7159) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_cove_checksum_method_wordvalidator_is_all_words_valid() != 17704) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_cove_checksum_method_wordvalidator_is_valid_word_group() != 6393) { + if (uniffi_cove_checksum_method_wordvalidator_is_complete() != 18257) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_wordvalidator_is_word_correct() != 39689) { diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift index 074d84c4..e12f929d 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift @@ -51,6 +51,12 @@ struct VerifyWordsScreen: View { // private @State private var wordNumber: Int @State private var possibleWords: [String] + @State private var checkState: CheckState = .none + + @State private var clicks = 0 + @State private var inSelectionProgress = false + + @Namespace private var namespace // alerts private enum AlertType: Identifiable { @@ -113,37 +119,176 @@ struct VerifyWordsScreen: View { } } + @MainActor + func selectWord(_ word: String) { + // if in the middle of a correct check, ignore + if case .correct = checkState { return } + if case .checking = checkState { return } + + if checkState == .none { + let animation = if validator.isWordCorrect(word: word, for: UInt8(wordNumber)) { + Animation.spring().speed(2.5) + } else { + Animation.spring().speed(1.5) + } + + withAnimation(animation) { + checkState = .checking(word) + } completion: { + checkWord(word) + } + return + } + + // if already in the middle of another selection ignore + if inSelectionProgress { return } + + inSelectionProgress = true + withAnimation(.spring().speed(5), completionCriteria: .removed) { + checkState = .none + } completion: { + selectWord(word) + } + } + + @MainActor + func deselectWord(_ animation: Animation = .spring(), completion: @escaping () -> Void = {}) { + withAnimation(animation) { + checkState = .none + } completion: { + inSelectionProgress = false + clicks += 1 + completion() + } + } + + @MainActor + func checkWord(_ word: String) { + if validator.isWordCorrect(word: word, for: UInt8(wordNumber)) { + withAnimation(Animation.spring().speed(2)) + { checkState = .correct(word) } + completion: { nextWord() } + } else { + withAnimation(Animation.spring().speed(1.25)) + { checkState = .incorrect(word) } + completion: { deselectWord(.spring().speed(2)) } + } + } + + @MainActor + func nextWord() { + inSelectionProgress = false + clicks += 1 + + if validator.isComplete(wordNumber: UInt8(wordNumber)) { + // TODO: complete validation + return + } + + withAnimation(.spring().speed(3)) { + wordNumber += 1 + possibleWords = validator.possibleWords(for: UInt8(wordNumber)) + } completion: { + deselectWord(.spring().speed(2.5)) + } + } + + func matchedGeoId(for word: String) -> String { + "\(wordNumber)-\(word)-\(clicks)" + } + + var checkingWordBg: Color { + switch checkState { + case .correct: + .green + case .incorrect: + .red + default: + .btnPrimary + } + } + + var checkingWordColor: Color { + switch checkState { + case .correct, .incorrect: + Color.white + default: + Color.midnightBlue.opacity(0.90) + } + } + + var isDisabled: Bool { + if inSelectionProgress, checkState != .none { + return true + } + + return false + } + var columns: [GridItem] { let item = GridItem(.adaptive(minimum: screenWidth * 0.25 - 20)) return [item, item, item, item] } var body: some View { - VStack(spacing: 48) { + VStack(spacing: 24) { Spacer() Text("What is word #\(wordNumber)?") .foregroundStyle(.white) .font(.title2) .fontWeight(.semibold) - Rectangle().frame(width: 200, height: 1) - .foregroundColor(.white) - - LazyVGrid(columns: columns, spacing: 20) { - ForEach(Array(possibleWords.enumerated()), id: \.offset) { _, word in - Button(action: {}) { - Text(word) + VStack(spacing: 10) { + if let checkingWord = checkState.word { + Button(action: { deselectWord() }) { + Text(checkingWord) .font(.caption) - .foregroundStyle(.midnightBlue.opacity(0.90)) + .foregroundStyle(checkingWordColor) .multilineTextAlignment(.center) .frame(alignment: .leading) .minimumScaleFactor(0.90) .lineLimit(1) + .padding(.horizontal) + .padding(.vertical, 12) + .background(checkingWordBg) + .cornerRadius(10) + } + .matchedGeometryEffect(id: matchedGeoId(for: checkingWord), in: namespace) + } else { + // take up the same space + Text("") + .padding(.vertical, 12) + } + + Rectangle().frame(width: 200, height: 1) + .foregroundColor(.white) + } + + LazyVGrid(columns: columns, spacing: 20) { + ForEach(Array(possibleWords.enumerated()), id: \.offset) { _, word in + Group { + if checkState.word ?? "" != word { + Button(action: { selectWord(word) }) { + Text(word) + .font(.caption) + .foregroundStyle(.midnightBlue.opacity(0.90)) + .multilineTextAlignment(.center) + .frame(alignment: .leading) + .minimumScaleFactor(0.50) + .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) + } + .disabled(isDisabled) + .contentShape(Rectangle()) + .padding(.horizontal) + .padding(.vertical, 12) + .background(Color.btnPrimary) + .cornerRadius(10) + .matchedGeometryEffect(id: matchedGeoId(for: word), in: namespace) + } else { + Text(word).opacity(0) + } } - .padding(.horizontal) - .padding(.vertical, 12) - .background(Color.btnPrimary) - .cornerRadius(10) } } @@ -159,14 +304,19 @@ struct VerifyWordsScreen: View { Text("Verify your recovery words") .font(.system(size: 38, weight: .semibold)) .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) Spacer() } - Text("Your secret recovery words are the only way to recover your wallet if you lose your phone or switch to a different wallet. Once you leave this screen, you won’t be able to view them again.") - .font(.subheadline) - .foregroundStyle(.lightGray) - .opacity(0.75) + HStack { + Text("Your secret recovery words are the only way to recover your wallet if you lose your phone or switch to a different wallet. Once you leave this screen, you won’t be able to view them again.") + .font(.subheadline) + .foregroundStyle(.lightGray) + .opacity(0.75) + + Spacer() + } } } .padding() @@ -186,6 +336,23 @@ struct VerifyWordsScreen: View { } } +enum CheckState: Equatable { + case none, checking(String), correct(String), incorrect(String) + + var word: String? { + switch self { + case .checking(let word): + word + case .correct(let word): + word + case .incorrect(let word): + word + case .none: + nil + } + } +} + #Preview { struct Container: View { @State var model = WalletViewModel(preview: "preview_only") diff --git a/ios/Cove/Views/SidebarView.swift b/ios/Cove/Views/SidebarView.swift index 5ae499d3..8347126e 100644 --- a/ios/Cove/Views/SidebarView.swift +++ b/ios/Cove/Views/SidebarView.swift @@ -153,8 +153,10 @@ struct SidebarView: View { Task { try? await Task.sleep(for: .milliseconds(300)) - if case Route.selectedWallet = route { - return app.loadAndReset(to: route) + if case let Route.selectedWallet(id: id) = route { + let selected: ()? = try? app.rust.selectWallet(id: id) + if selected == nil { app.loadAndReset(to: route) } + return } if !app.hasWallets, route == Route.newWallet(.select) { diff --git a/rust/src/word_validator.rs b/rust/src/word_validator.rs index b7106db9..d6fd895c 100644 --- a/rust/src/word_validator.rs +++ b/rust/src/word_validator.rs @@ -1,10 +1,11 @@ use bip39::Mnemonic; use rand::seq::SliceRandom; -use crate::mnemonic::{GroupedWord, NumberOfBip39Words, WordAccess as _}; +use crate::mnemonic::NumberOfBip39Words; #[derive(Debug, Clone, uniffi::Object)] pub struct WordValidator { + #[allow(dead_code)] mnemonic: Mnemonic, words: Vec<&'static str>, } @@ -46,8 +47,29 @@ impl WordValidator { .map(|word| word.to_string()) .collect(); - combined.shuffle(&mut rng); + // remove last word from the list + if word_index > 0 { + let last_word = self.words[word_index - 1]; + combined.retain(|word| word != last_word); + } + + combined.sort(); + combined.dedup(); + + // make sure we have 12 words + while combined.len() < 12 { + let needed = 12 - combined.len(); + let new_words = NumberOfBip39Words::Twelve.to_mnemonic(); + + new_words.words().take(needed).for_each(|word| { + combined.push(word.to_string()); + }); + + combined.sort(); + combined.dedup(); + } + combined.sort_unstable(); combined } @@ -64,61 +86,12 @@ impl WordValidator { correct_word == word } - // OLD API - // TODO: remove this if no longer used - - // get the grouped words - #[uniffi::method(default(groups_of = 12))] - pub fn grouped_words(&self, groups_of: u8) -> Vec> { - self.mnemonic.grouped_words_of(groups_of as usize) - } - - // check if the word group passed in is valid #[uniffi::method] - pub fn is_valid_word_group(&self, group_number: u8, entered_words: Vec) -> bool { - let actual_words = &self.mnemonic.grouped_words_of(6)[group_number as usize]; - - for (actual_word, entered_word) in actual_words.iter().zip(entered_words.iter()) { - if !entered_word.trim().eq_ignore_ascii_case(&actual_word.word) { - return false; - } - } - - true - } - - // check if all the word groups are valid - #[uniffi::method] - pub fn is_all_words_valid(&self, entered_words: Vec>) -> bool { - let entered_words = entered_words.iter().flat_map(|words| words.iter()); - - for (actual_word, entered_word) in self.mnemonic.words().zip(entered_words) { - if !entered_word.trim().eq_ignore_ascii_case(actual_word) { - return false; - } - } - - true - } - - // get string of all invalid words - #[uniffi::method] - pub fn invalid_words_string(&self, entered_words: Vec>) -> String { - let entered_words = entered_words.iter().flat_map(|words| words.iter()); - - let mut invalid_words = Vec::new(); - for (index, (actual_word, entered_word)) in - self.mnemonic.words().zip(entered_words).enumerate() - { - if !entered_word.trim().eq_ignore_ascii_case(actual_word) { - invalid_words.push((index + 1).to_string()); - } - } - - invalid_words.join(", ") + pub fn is_complete(&self, word_number: u8) -> bool { + word_number == self.words.len() as u8 } - // preview only + // MARK: preview only #[uniffi::constructor(name = "preview", default(number_of_words = None))] pub fn preview(preview: bool, number_of_words: Option) -> Self { assert!(preview); From 83aad9938d2cb12b1967583863afa35597a67cf3 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 4 Dec 2024 12:20:54 -0600 Subject: [PATCH 04/15] Show skip and show words buttons on verify screen --- .../HotWallet/HotWalletCreateScreen.swift | 61 ++++++++++++------- .../HotWallet/HotWalletSelectScreen.swift | 5 +- .../HotWallet/VerifyWordsScreen.swift | 33 +++++++++- rust/src/database/wallet.rs | 3 +- rust/src/view_model/wallet.rs | 8 ++- 5 files changed, 79 insertions(+), 31 deletions(-) diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift index e1ff1d17..5da2f12e 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift @@ -44,7 +44,6 @@ struct WordsView: View { WordCardView(words: wordGroup).tag(index) } } - .frame(height: screenHeight * 0.50) Spacer() @@ -62,10 +61,16 @@ struct WordsView: View { Spacer() } - Text("Your secret recovery words are the only way to recover your wallet if you lose your phone or switch to a different wallet. Once you leave this screen, you won’t be able to view them again.") - .font(.subheadline) - .foregroundStyle(.lightGray) - .opacity(0.70) + HStack { + Text("Your secret recovery words are the only way to recover your wallet if you lose your phone or switch to a different wallet. Once you leave this screen, you won’t be able to view them again.") + .font(.subheadline) + .foregroundStyle(.lightGray) + .multilineTextAlignment(.leading) + .opacity(0.70) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + } HStack { Text("Please save these words in a secure location.") @@ -81,10 +86,10 @@ struct WordsView: View { Divider() .overlay(.lightGray.opacity(0.50)) - VStack(spacing: 14) { + VStack(spacing: 24) { Group { if tabIndex == lastIndex { - Button("Save Wallet") { + Button(action: { do { // save the wallet let walletId = try model.rust.saveWallet().id @@ -96,23 +101,35 @@ struct WordsView: View { // TODO: handle, maybe show an alert? Log.error("Error \(error)") } + }) { + Text("Save Wallet") + .font(.subheadline) + .fontWeight(.medium) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .padding(.vertical, 20) + .padding(.horizontal, 10) + .background(Color.btnPrimary) + .foregroundColor(.midnightBlue) + .cornerRadius(10) } } else { - Button("Next") { - withAnimation { - tabIndex += 1 - } + Button(action: { + withAnimation { tabIndex += 1 } + }) { + Text("Next") + .font(.subheadline) + .fontWeight(.medium) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .padding(.vertical, 20) + .padding(.horizontal, 10) + .background(Color.btnPrimary) + .foregroundColor(.midnightBlue) + .cornerRadius(10) } } } - .font(.subheadline) - .fontWeight(.medium) - .frame(maxWidth: .infinity) - .padding(.vertical, 20) - .padding(.horizontal, 10) - .background(Color.btnPrimary) - .foregroundColor(.midnightBlue) - .cornerRadius(10) } } .padding() @@ -134,7 +151,6 @@ struct WordsView: View { }) { HStack { Image(systemName: "chevron.left") - Text("Back") } .foregroundStyle(.white) } @@ -156,6 +172,7 @@ struct WordsView: View { secondaryButton: .cancel(Text("Cancel")) ) } + .navigationBarBackButtonHidden(true) } } @@ -167,6 +184,7 @@ struct WordCardView: View { ForEach(words, id: \.self) { group in HStack(spacing: 0) { Text("\(String(format: "%d", group.number)). ") + .fontWeight(.medium) .foregroundColor(.black.opacity(0.5)) .multilineTextAlignment(.leading) .lineLimit(1) @@ -176,6 +194,7 @@ struct WordCardView: View { Spacer() Text(group.word) + .fontWeight(.medium) .foregroundStyle(.midnightBlue) .multilineTextAlignment(.center) .frame(alignment: .leading) @@ -201,7 +220,7 @@ struct StyledWordCard: View { var body: some View { TabView(selection: $tabIndex) { - content + content.padding(.bottom, 20) } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic)) } diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletSelectScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletSelectScreen.swift index 28245a6d..6da0796d 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletSelectScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletSelectScreen.swift @@ -46,7 +46,7 @@ struct HotWalletSelectScreen: View { Divider() .overlay(.lightGray.opacity(0.50)) - VStack(spacing: 14) { + VStack(spacing: 24) { Button(action: { isSheetShown = true; nextScreen = .create }) { Text("Create new wallet") .font(.subheadline) @@ -64,11 +64,8 @@ struct HotWalletSelectScreen: View { .font(.subheadline) .fontWeight(.medium) .frame(maxWidth: .infinity) - .padding(.vertical, 20) - .padding(.horizontal, 10) .foregroundColor(.white) } - .buttonStyle(PlainButtonStyle()) } } .padding() diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift index e12f929d..3774c131 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift @@ -181,8 +181,7 @@ struct VerifyWordsScreen: View { clicks += 1 if validator.isComplete(wordNumber: UInt8(wordNumber)) { - // TODO: complete validation - return + return confirm(model, validator) } withAnimation(.spring().speed(3)) { @@ -243,6 +242,7 @@ struct VerifyWordsScreen: View { Button(action: { deselectWord() }) { Text(checkingWord) .font(.caption) + .fontWeight(.medium) .foregroundStyle(checkingWordColor) .multilineTextAlignment(.center) .frame(alignment: .leading) @@ -291,6 +291,7 @@ struct VerifyWordsScreen: View { } } } + .padding(.vertical) Spacer() @@ -310,14 +311,40 @@ struct VerifyWordsScreen: View { } HStack { - Text("Your secret recovery words are the only way to recover your wallet if you lose your phone or switch to a different wallet. Once you leave this screen, you won’t be able to view them again.") + Text("To confirm that you've securely saved your recovery phrase, please drag and drop the word into their correct positions.") .font(.subheadline) .foregroundStyle(.lightGray) .opacity(0.75) + .fixedSize(horizontal: false, vertical: true) Spacer() } } + + Divider() + .overlay(.lightGray.opacity(0.50)) + + VStack(spacing: 16) { + Button(action: { activeAlert = .words }) { + Text("Show Words") + .font(.footnote) + .fontWeight(.medium) + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + .padding(.horizontal, 10) + .background(Color.btnPrimary) + .foregroundColor(.midnightBlue) + .cornerRadius(10) + } + + Button(action: { activeAlert = .skip }) { + Text("Skip Verification") + .foregroundStyle(.white) + .font(.caption) + .fontWeight(.medium) + } + } + .padding(.bottom, 32) } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/rust/src/database/wallet.rs b/rust/src/database/wallet.rs index 5d2e8843..eeb49da2 100644 --- a/rust/src/database/wallet.rs +++ b/rust/src/database/wallet.rs @@ -4,7 +4,7 @@ use redb::{ReadOnlyTable, ReadableTableMetadata, TableDefinition}; use tracing::debug; use crate::{ - app::reconcile::{AppStateReconcileMessage, Updater}, + app::reconcile::{AppStateReconcileMessage, Update, Updater}, network::Network, redb::Json, wallet::metadata::{WalletId, WalletMetadata}, @@ -100,6 +100,7 @@ impl WalletsTable { }); self.save_all_wallets(network, wallets)?; + Updater::send_update(Update::DatabaseUpdated); Ok(()) } diff --git a/rust/src/view_model/wallet.rs b/rust/src/view_model/wallet.rs index b8485c92..8965bd35 100644 --- a/rust/src/view_model/wallet.rs +++ b/rust/src/view_model/wallet.rs @@ -554,12 +554,16 @@ impl RustWalletViewModel { #[uniffi::method] pub fn mark_wallet_as_verified(&self) -> Result<(), Error> { - let wallet_metadata = &self.metadata.read(); + { + let mut wallet_metadata = self.metadata.write(); + wallet_metadata.verified = true; + } + let id = self.metadata.read().id.clone(); let database = Database::global(); database .wallets - .mark_wallet_as_verified(wallet_metadata.id.clone()) + .mark_wallet_as_verified(id) .map_err(Error::MarkWalletAsVerifiedError)?; Ok(()) From f34089d4d92d6069e81e0f33f43007d5b352a40a Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 4 Dec 2024 14:11:33 -0600 Subject: [PATCH 05/15] Fix spacing for mini phones --- ios/Cove/Constants.swift | 2 + .../HotWallet/HotWalletSelectScreen.swift | 11 ++++- .../{ => VerifyWords}/VerifyWordsScreen.swift | 42 +++++++++++++------ .../NewWalletFlow/NewWalletSelectScreen.swift | 1 + ios/Cove/QrCodeScanView.swift | 14 +++---- 5 files changed, 48 insertions(+), 22 deletions(-) rename ios/Cove/Flows/NewWalletFlow/HotWallet/{ => VerifyWords}/VerifyWordsScreen.swift (92%) diff --git a/ios/Cove/Constants.swift b/ios/Cove/Constants.swift index 6f63fd07..37efd74e 100644 --- a/ios/Cove/Constants.swift +++ b/ios/Cove/Constants.swift @@ -9,3 +9,5 @@ import SwiftUI let screenHeight = UIScreen.main.bounds.height let screenWidth = UIScreen.main.bounds.width + +let isMiniDevice = screenHeight <= 812 diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletSelectScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletSelectScreen.swift index 6da0796d..00607e32 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletSelectScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletSelectScreen.swift @@ -47,7 +47,10 @@ struct HotWalletSelectScreen: View { .overlay(.lightGray.opacity(0.50)) VStack(spacing: 24) { - Button(action: { isSheetShown = true; nextScreen = .create }) { + Button(action: { + isSheetShown = true + nextScreen = .create + }) { Text("Create new wallet") .font(.subheadline) .fontWeight(.medium) @@ -59,7 +62,10 @@ struct HotWalletSelectScreen: View { .cornerRadius(10) } - Button(action: { isSheetShown = true; nextScreen = .import_ }) { + Button(action: { + isSheetShown = true + nextScreen = .import_ + }) { Text("Import existing wallet") .font(.subheadline) .fontWeight(.medium) @@ -83,6 +89,7 @@ struct HotWalletSelectScreen: View { .toolbar { ToolbarItem(placement: .principal) { Text("Add New Wallet") + .font(.callout) .fontWeight(.semibold) .foregroundStyle(.white) } diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift similarity index 92% rename from ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift rename to ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift index 3774c131..e6a492cd 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWordsScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift @@ -13,6 +13,7 @@ struct VerifyWordsContainer: View { @Environment(MainViewModel.self) private var app let id: WalletId + @State private var verificationComplete = false @State private var model: WalletViewModel? = nil @State private var validator: WordValidator? = nil @@ -29,11 +30,25 @@ struct VerifyWordsContainer: View { } var body: some View { - if let model, let validator { - VerifyWordsScreen(model: model, validator: validator) - } else { - Text("Loading....") - .onAppear(perform: initOnAppear) + Group { + if let model, let validator { + if verificationComplete { + VerificationCompleteScreen(model: model) + } else { + VerifyWordsScreen(model: model, validator: validator) + } + } else { + Text("Loading....") + .onAppear(perform: initOnAppear) + } + } + .toolbar { + ToolbarItem(placement: .principal) { + Text("Verify Recovery Words") + .foregroundStyle(.white) + .font(.callout) + .fontWeight(.semibold) + } } } } @@ -231,7 +246,6 @@ struct VerifyWordsScreen: View { var body: some View { VStack(spacing: 24) { - Spacer() Text("What is word #\(wordNumber)?") .foregroundStyle(.white) .font(.title2) @@ -293,7 +307,7 @@ struct VerifyWordsScreen: View { } .padding(.vertical) - Spacer() + if !isMiniDevice { Spacer() } HStack { DotMenuView(selected: 3, size: 5) @@ -312,7 +326,7 @@ struct VerifyWordsScreen: View { HStack { Text("To confirm that you've securely saved your recovery phrase, please drag and drop the word into their correct positions.") - .font(.subheadline) + .font(.footnote) .foregroundStyle(.lightGray) .opacity(0.75) .fixedSize(horizontal: false, vertical: true) @@ -321,6 +335,8 @@ struct VerifyWordsScreen: View { } } + if !isMiniDevice { Spacer() } + Divider() .overlay(.lightGray.opacity(0.50)) @@ -344,10 +360,10 @@ struct VerifyWordsScreen: View { .fontWeight(.medium) } } - .padding(.bottom, 32) + // mini and se only + .safeAreaPadding(.bottom, isMiniDevice ? 20 : 0) } .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity) .alert(item: $activeAlert) { alertType in DisplayAlert(for: alertType) } @@ -392,7 +408,9 @@ enum CheckState: Equatable { } return - AsyncPreview { - Container() + NavigationStack { + AsyncPreview { + Container() + } } } diff --git a/ios/Cove/Flows/NewWalletFlow/NewWalletSelectScreen.swift b/ios/Cove/Flows/NewWalletFlow/NewWalletSelectScreen.swift index ac251c0f..607d83d8 100644 --- a/ios/Cove/Flows/NewWalletFlow/NewWalletSelectScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/NewWalletSelectScreen.swift @@ -151,6 +151,7 @@ struct NewWalletSelectScreen: View { .toolbar { ToolbarItem(placement: .principal) { Text("Add New Wallet") + .font(.callout) .fontWeight(.semibold) .foregroundStyle(.white) } diff --git a/ios/Cove/QrCodeScanView.swift b/ios/Cove/QrCodeScanView.swift index 1bd51f9b..d540a937 100644 --- a/ios/Cove/QrCodeScanView.swift +++ b/ios/Cove/QrCodeScanView.swift @@ -22,8 +22,6 @@ struct QrCodeScanView: View { @State private var totalParts: Int? = nil @State private var partsLeft: Int? = nil - private let screenHeight = UIScreen.main.bounds.height - var alertState: Binding?> { $app.alertState } @@ -98,12 +96,12 @@ struct QrCodeScanView: View { do { let multiQr: MultiQr = try multiQr - ?? { - let newMultiQr = try MultiQr.tryNew(qr: qr) - self.multiQr = newMultiQr - totalParts = Int(newMultiQr.totalParts()) - return newMultiQr - }() + ?? { + let newMultiQr = try MultiQr.tryNew(qr: qr) + self.multiQr = newMultiQr + totalParts = Int(newMultiQr.totalParts()) + return newMultiQr + }() // single QR if !multiQr.isBbqr() { From 33f3f763e0946d5816ee190ff604c90ce5765dc4 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 4 Dec 2024 15:00:33 -0600 Subject: [PATCH 06/15] Create PrimaryButtonStyle.swift --- ios/Cove/Styles/PrimaryButtonStyle.swift | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 ios/Cove/Styles/PrimaryButtonStyle.swift diff --git a/ios/Cove/Styles/PrimaryButtonStyle.swift b/ios/Cove/Styles/PrimaryButtonStyle.swift new file mode 100644 index 00000000..1d014c67 --- /dev/null +++ b/ios/Cove/Styles/PrimaryButtonStyle.swift @@ -0,0 +1,23 @@ +// +// PrimaryButtonStyle.swift +// Cove +// +// Created by Praveen Perera on 12/4/24. +// + +import SwiftUI + +struct PrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.footnote) + .fontWeight(.medium) + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + .padding(.horizontal, 10) + .background(Color.btnPrimary) + .foregroundColor(.midnightBlue) + .cornerRadius(10) + .opacity(configuration.isPressed ? 0.8 : 1.0) + } +} From c016ec6b5b26603d4232caedef0efb11a356c79e Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 4 Dec 2024 15:19:21 -0600 Subject: [PATCH 07/15] Create final verified wallet screen --- ios/Cove/Extention/Color+Ext.swift | 4 + .../VerificationCompleteScreen.swift | 87 +++++++++++++++++++ .../VerifyWords/VerifyWordsScreen.swift | 12 +-- 3 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerificationCompleteScreen.swift diff --git a/ios/Cove/Extention/Color+Ext.swift b/ios/Cove/Extention/Color+Ext.swift index fe4fa5a1..bc0da359 100644 --- a/ios/Cove/Extention/Color+Ext.swift +++ b/ios/Cove/Extention/Color+Ext.swift @@ -13,6 +13,10 @@ extension Color { Color(hue: 0.61, saturation: 0.04, brightness: 0.83, opacity: 1.00) } + static var lightGreen: Color { + Color(red: 0.463, green: 0.898, blue: 0.584) // #76e595 + } + init(_ color: WalletColor) { self = color.toColor() } diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerificationCompleteScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerificationCompleteScreen.swift new file mode 100644 index 00000000..f419ae4e --- /dev/null +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerificationCompleteScreen.swift @@ -0,0 +1,87 @@ +// +// VerificationCompleteScreen.swift +// Cove +// +// Created by Praveen Perera on 12/4/24. +// + +import Foundation +import SwiftUI + +struct VerificationCompleteScreen: View { + @Environment(MainViewModel.self) var app + + // args + let model: WalletViewModel + + var body: some View { + VStack(spacing: 24) { + Spacer() + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: screenWidth * 0.46)) + .fontWeight(.light) + .symbolRenderingMode(.palette) + .foregroundStyle(.midnightBlue, Color.lightGreen) + + Spacer() + Spacer() + Spacer() + + HStack { + DotMenuView(selected: 3, size: 5) + Spacer() + } + + VStack(spacing: 12) { + HStack { + Text("You're all set!") + .font(.system(size: 38, weight: .semibold)) + .foregroundStyle(.white) + + Spacer() + } + + HStack { + Text("All set! You’ve successfully verified your recovery words and can now access your wallet.") + .font(.footnote) + .foregroundStyle(.lightGray.opacity(0.75)) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + } + } + + Divider().overlay(Color.lightGray.opacity(0.50)) + + Button("Go To Wallet") { + do { + try model.rust.markWalletAsVerified() + app.resetRoute(to: Route.selectedWallet(model.id)) + } catch { + Log.error("Error marking wallet as verified: \(error)") + } + } + .buttonStyle(PrimaryButtonStyle()) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + Image(.newWalletPattern) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: screenHeight * 0.75, alignment: .topTrailing) + .frame(maxWidth: .infinity) + .opacity(0.75) + ) + .background(Color.midnightBlue) + } +} + +#Preview { + AsyncPreview { + VerificationCompleteScreen(model: WalletViewModel(preview: "preview_only")) + .environment(MainViewModel()) + } +} diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift index e6a492cd..daca5c3d 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift @@ -327,8 +327,7 @@ struct VerifyWordsScreen: View { HStack { Text("To confirm that you've securely saved your recovery phrase, please drag and drop the word into their correct positions.") .font(.footnote) - .foregroundStyle(.lightGray) - .opacity(0.75) + .foregroundStyle(.lightGray.opacity(0.75)) .fixedSize(horizontal: false, vertical: true) Spacer() @@ -343,15 +342,8 @@ struct VerifyWordsScreen: View { VStack(spacing: 16) { Button(action: { activeAlert = .words }) { Text("Show Words") - .font(.footnote) - .fontWeight(.medium) - .frame(maxWidth: .infinity) - .padding(.vertical, 20) - .padding(.horizontal, 10) - .background(Color.btnPrimary) - .foregroundColor(.midnightBlue) - .cornerRadius(10) } + .buttonStyle(PrimaryButtonStyle()) Button(action: { activeAlert = .skip }) { Text("Skip Verification") From 30e441ba002afd9971327a44804501f459472dea Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 4 Dec 2024 16:40:07 -0600 Subject: [PATCH 08/15] Show wallet verified right away --- rust/src/view_model/wallet.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/rust/src/view_model/wallet.rs b/rust/src/view_model/wallet.rs index 8965bd35..1c6ca106 100644 --- a/rust/src/view_model/wallet.rs +++ b/rust/src/view_model/wallet.rs @@ -557,10 +557,17 @@ impl RustWalletViewModel { { let mut wallet_metadata = self.metadata.write(); wallet_metadata.verified = true; + + self.reconciler + .send(WalletViewModelReconcileMessage::WalletMetadataChanged( + wallet_metadata.clone(), + )) + .expect("failed to send update"); } let id = self.metadata.read().id.clone(); let database = Database::global(); + database .wallets .mark_wallet_as_verified(id) From fb6b7d3b5a6966c7b4524d053f1d50024c655bc2 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 4 Dec 2024 16:40:24 -0600 Subject: [PATCH 09/15] Adjust word verification flow interaction settings --- .../xcdebugger/Breakpoints_v2.xcbkptlist | 32 ----- .../VerifyWords/VerifyWordsScreen.swift | 132 +++++++++--------- rust/src/word_validator.rs | 2 +- 3 files changed, 69 insertions(+), 97 deletions(-) diff --git a/ios/Cove.xcodeproj/xcuserdata/praveen.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/ios/Cove.xcodeproj/xcuserdata/praveen.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index d075fe26..8b97498c 100644 --- a/ios/Cove.xcodeproj/xcuserdata/praveen.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/ios/Cove.xcodeproj/xcuserdata/praveen.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,36 +3,4 @@ uuid = "C7F52075-AA97-441B-886D-D6C28336D590" type = "1" version = "2.0"> - - - - - - - - - - diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift index daca5c3d..6fd80902 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift @@ -13,7 +13,7 @@ struct VerifyWordsContainer: View { @Environment(MainViewModel.self) private var app let id: WalletId - @State private var verificationComplete = false + @State var verificationComplete = false @State private var model: WalletViewModel? = nil @State private var validator: WordValidator? = nil @@ -29,14 +29,31 @@ struct VerifyWordsContainer: View { } } + @ViewBuilder + func LoadedScreen(model: WalletViewModel, validator: WordValidator) -> some View { + if verificationComplete { + VerificationCompleteScreen(model: model) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) + } else { + VerifyWordsScreen( + model: model, + validator: validator, + verificationComplete: $verificationComplete + ) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) + } + } + var body: some View { Group { if let model, let validator { - if verificationComplete { - VerificationCompleteScreen(model: model) - } else { - VerifyWordsScreen(model: model, validator: validator) - } + LoadedScreen(model: model, validator: validator) } else { Text("Loading....") .onAppear(perform: initOnAppear) @@ -62,14 +79,13 @@ struct VerifyWordsScreen: View { // args let model: WalletViewModel let validator: WordValidator + @Binding var verificationComplete: Bool // private @State private var wordNumber: Int @State private var possibleWords: [String] @State private var checkState: CheckState = .none - - @State private var clicks = 0 - @State private var inSelectionProgress = false + @State private var incorrectGuesses = 0 @Namespace private var namespace @@ -85,18 +101,16 @@ struct VerifyWordsScreen: View { model.walletMetadata.id } - init(model: WalletViewModel, validator: WordValidator) { + init(model: WalletViewModel, validator: WordValidator, verificationComplete: Binding) { self.model = model self.validator = validator + _verificationComplete = verificationComplete + wordNumber = 1 possibleWords = validator.possibleWords(for: 1) } - var buttonIsDisabled: Bool { - true - } - private func DisplayAlert(for alertType: AlertType) -> Alert { switch alertType { case .words: @@ -125,44 +139,24 @@ struct VerifyWordsScreen: View { } } - func confirm(_ model: WalletViewModel, _: WordValidator) { - do { - try model.rust.markWalletAsVerified() - app.resetRoute(to: Route.selectedWallet(id)) - } catch { - Log.error("Error marking wallet as verified: \(error)") - } - } - @MainActor func selectWord(_ word: String) { - // if in the middle of a correct check, ignore - if case .correct = checkState { return } - if case .checking = checkState { return } - - if checkState == .none { - let animation = if validator.isWordCorrect(word: word, for: UInt8(wordNumber)) { - Animation.spring().speed(2.5) - } else { - Animation.spring().speed(1.5) - } - - withAnimation(animation) { - checkState = .checking(word) - } completion: { - checkWord(word) - } + // if already checking, skip + if checkState != .none { + withAnimation(.spring().speed(6)) { checkState = .none } return } - // if already in the middle of another selection ignore - if inSelectionProgress { return } + let animation = if validator.isWordCorrect(word: word, for: UInt8(wordNumber)) { + Animation.spring().speed(2.25) + } else { + Animation.spring().speed(1.75) + } - inSelectionProgress = true - withAnimation(.spring().speed(5), completionCriteria: .removed) { - checkState = .none + withAnimation(animation) { + checkState = .checking(word) } completion: { - selectWord(word) + checkWord(word) } } @@ -171,8 +165,6 @@ struct VerifyWordsScreen: View { withAnimation(animation) { checkState = .none } completion: { - inSelectionProgress = false - clicks += 1 completion() } } @@ -180,23 +172,27 @@ struct VerifyWordsScreen: View { @MainActor func checkWord(_ word: String) { if validator.isWordCorrect(word: word, for: UInt8(wordNumber)) { - withAnimation(Animation.spring().speed(2)) + withAnimation(Animation.spring().speed(3), completionCriteria: .removed) { checkState = .correct(word) } completion: { nextWord() } } else { - withAnimation(Animation.spring().speed(1.25)) + withAnimation(Animation.spring().speed(2)) { checkState = .incorrect(word) } - completion: { deselectWord(.spring().speed(2)) } + completion: { + deselectWord(.spring().speed(3), completion: { + incorrectGuesses += 1 + }) + } } } @MainActor func nextWord() { - inSelectionProgress = false - clicks += 1 - if validator.isComplete(wordNumber: UInt8(wordNumber)) { - return confirm(model, validator) + withAnimation(.easeInOut(duration: 0.3)) { + verificationComplete = true + } + return } withAnimation(.spring().speed(3)) { @@ -208,7 +204,7 @@ struct VerifyWordsScreen: View { } func matchedGeoId(for word: String) -> String { - "\(wordNumber)-\(word)-\(clicks)" + "\(wordNumber)-\(word)-\(incorrectGuesses)" } var checkingWordBg: Color { @@ -232,11 +228,7 @@ struct VerifyWordsScreen: View { } var isDisabled: Bool { - if inSelectionProgress, checkState != .none { - return true - } - - return false + checkState != .none } var columns: [GridItem] { @@ -267,7 +259,11 @@ struct VerifyWordsScreen: View { .background(checkingWordBg) .cornerRadius(10) } - .matchedGeometryEffect(id: matchedGeoId(for: checkingWord), in: namespace) + .matchedGeometryEffect( + id: matchedGeoId(for: checkingWord), + in: namespace, + isSource: checkState != .none + ) } else { // take up the same space Text("") @@ -298,7 +294,11 @@ struct VerifyWordsScreen: View { .padding(.vertical, 12) .background(Color.btnPrimary) .cornerRadius(10) - .matchedGeometryEffect(id: matchedGeoId(for: word), in: namespace) + .matchedGeometryEffect( + id: matchedGeoId(for: word), + in: namespace, + isSource: checkState == .none + ) } else { Text(word).opacity(0) } @@ -394,8 +394,12 @@ enum CheckState: Equatable { @State var validator = WordValidator.preview(preview: true) var body: some View { - VerifyWordsScreen(model: model, validator: validator) - .environment(MainViewModel()) + VerifyWordsScreen( + model: model, + validator: validator, + verificationComplete: Binding.constant(false) + ) + .environment(MainViewModel()) } } diff --git a/rust/src/word_validator.rs b/rust/src/word_validator.rs index d6fd895c..eeac5a86 100644 --- a/rust/src/word_validator.rs +++ b/rust/src/word_validator.rs @@ -24,7 +24,7 @@ impl WordValidator { pub fn possible_words(&self, for_: u8) -> Vec { let Some(word_index) = for_.checked_sub(1) else { return vec![] }; let word_index = word_index as usize; - if word_index > self.words.len() { + if word_index >= self.words.len() { return vec![]; } From f5bbf5c84ea3ac6ed77d1781c1b8148f896ec406 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 4 Dec 2024 16:41:36 -0600 Subject: [PATCH 10/15] Clippy and format --- .../HotWallet/VerifyWords/VerifyWordsScreen.swift | 6 +++--- ios/Cove/QrCodeScanView.swift | 12 ++++++------ rust/src/word_validator.rs | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift index 6fd80902..a1eaf251 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift @@ -376,11 +376,11 @@ enum CheckState: Equatable { var word: String? { switch self { - case .checking(let word): + case let .checking(word): word - case .correct(let word): + case let .correct(word): word - case .incorrect(let word): + case let .incorrect(word): word case .none: nil diff --git a/ios/Cove/QrCodeScanView.swift b/ios/Cove/QrCodeScanView.swift index d540a937..1083e4ab 100644 --- a/ios/Cove/QrCodeScanView.swift +++ b/ios/Cove/QrCodeScanView.swift @@ -96,12 +96,12 @@ struct QrCodeScanView: View { do { let multiQr: MultiQr = try multiQr - ?? { - let newMultiQr = try MultiQr.tryNew(qr: qr) - self.multiQr = newMultiQr - totalParts = Int(newMultiQr.totalParts()) - return newMultiQr - }() + ?? { + let newMultiQr = try MultiQr.tryNew(qr: qr) + self.multiQr = newMultiQr + totalParts = Int(newMultiQr.totalParts()) + return newMultiQr + }() // single QR if !multiQr.isBbqr() { diff --git a/rust/src/word_validator.rs b/rust/src/word_validator.rs index eeac5a86..0438048c 100644 --- a/rust/src/word_validator.rs +++ b/rust/src/word_validator.rs @@ -29,7 +29,7 @@ impl WordValidator { } let mut rng = rand::thread_rng(); - let correct_word = self.words[word_index as usize]; + let correct_word = self.words[word_index]; let mut words_clone = self.words.clone(); words_clone.shuffle(&mut rng); From 6d380b129038475cf4acd2f441ff56799ea8b5e9 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 5 Dec 2024 09:51:10 -0600 Subject: [PATCH 11/15] Complete secret word show screen and adjust wording --- ios/Cove/CoveApp.swift | 10 +- .../HotWallet/HotWalletCreateScreen.swift | 2 +- .../VerifyWords/VerifyWordsScreen.swift | 2 +- .../SecretWordsScreen.swift | 129 +++++++++++++----- .../SelectedWalletScreen.swift | 4 +- 5 files changed, 100 insertions(+), 47 deletions(-) diff --git a/ios/Cove/CoveApp.swift b/ios/Cove/CoveApp.swift index 7d231aae..6fe798b9 100644 --- a/ios/Cove/CoveApp.swift +++ b/ios/Cove/CoveApp.swift @@ -378,14 +378,10 @@ struct CoveApp: App { var routeToTint: Color { switch model.router.routes.last { - case .send: - .white - case .selectedWallet: - .white - case .newWallet: - .white - default: + case .settings: .blue + default: + .white } } diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift index 5da2f12e..219359d8 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift @@ -62,7 +62,7 @@ struct WordsView: View { } HStack { - Text("Your secret recovery words are the only way to recover your wallet if you lose your phone or switch to a different wallet. Once you leave this screen, you won’t be able to view them again.") + Text("Your secret recovery words are the only way to recover your wallet if you lose your phone or switch to a different wallet.") .font(.subheadline) .foregroundStyle(.lightGray) .multilineTextAlignment(.leading) diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift index a1eaf251..a87f26d5 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift @@ -325,7 +325,7 @@ struct VerifyWordsScreen: View { } HStack { - Text("To confirm that you've securely saved your recovery phrase, please drag and drop the word into their correct positions.") + Text("To confirm that you've securely saved your recovery phrase, please select the correct word") .font(.footnote) .foregroundStyle(.lightGray.opacity(0.75)) .fixedSize(horizontal: false, vertical: true) diff --git a/ios/Cove/Flows/SelectedWalletFlow/SecretWordsScreen.swift b/ios/Cove/Flows/SelectedWalletFlow/SecretWordsScreen.swift index e7f8f6ad..548e5e5b 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/SecretWordsScreen.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/SecretWordsScreen.swift @@ -14,61 +14,118 @@ struct SecretWordsScreen: View { @State var words: Mnemonic? @State var errorMessage: String? - var cardPadding: CGFloat { - if let words, words.allWords().count > 12 { - 8 - } else { - 50 - } - } - var verticalSpacing: CGFloat { 15 } + let rowHeight = 30.0 + var numberOfRows: Int { + (words?.words().count ?? 24) / 3 + } + + var rows: [GridItem] { + Array(repeating: .init(.fixed(rowHeight)), count: numberOfRows) + } + var body: some View { - Group { - if let words { - VStack { + VStack { + Spacer() + + Group { + if let words { GroupBox { - HStack(alignment: .top, spacing: 20) { - VStack(alignment: .leading, spacing: verticalSpacing) { - ForEach(words.allWords().prefix(12), id: \.number) { word in - Text("\(String(format: "%02d", word.number)). \(word.word)") + LazyHGrid(rows: rows, spacing: 12) { + ForEach(words.allWords(), id: \.number) { word in + HStack { + Text("\(word.number).") + .fontWeight(.medium) + .foregroundStyle(.secondary) .fontDesign(.monospaced) .multilineTextAlignment(.leading) - } - } + .minimumScaleFactor(0.5) + + Text(word.word) + .fontWeight(.bold) + .fontDesign(.monospaced) + .multilineTextAlignment(.leading) + .minimumScaleFactor(0.75) + .lineLimit(1) + .fixedSize() - if words.allWords().count > 12 { - VStack(alignment: .leading, spacing: verticalSpacing) { - ForEach(words.allWords().dropFirst(12), id: \.number) { word in - Text("\(String(format: "%02d", word.number)). \(word.word)") - .fontDesign(.monospaced) - .multilineTextAlignment(.leading) - } + Spacer() } } } - .padding(.horizontal, cardPadding) } - .padding(.horizontal, 10) + .frame(maxHeight: rowHeight * CGFloat(numberOfRows) + 32) + .frame(width: screenWidth * 0.9) + .font(.caption) + } else { + Text(errorMessage ?? "Loading...") + } + + Spacer() + Spacer() + Spacer() + + VStack(spacing: 12) { + HStack { + Text("Recovery Words") + .font(.system(size: 36, weight: .semibold)) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + + Spacer() + } + + HStack { + Text("Your secret recovery words are the only way to recover your wallet if you lose your phone or switch to a different wallet. Whoever has you recovery words, controls your Bitcoin.") + .multilineTextAlignment(.leading) + .font(.footnote) + .foregroundStyle(.lightGray.opacity(0.75)) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + } + + HStack { + Text("Please save these words in a secure location.") + .font(.subheadline) + .multilineTextAlignment(.leading) + .fontWeight(.bold) + .foregroundStyle(.white) + .opacity(0.9) + + Spacer() + } } - } else { - Text(errorMessage ?? "Loading...") } } + .padding() .onAppear { - if words == nil { - do { - words = try Mnemonic(id: id) - } catch { - errorMessage = error.localizedDescription - } + guard words == nil else { return } + do { words = try Mnemonic(id: id) } + catch { errorMessage = error.localizedDescription } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .toolbar { + ToolbarItem(placement: .principal) { + Text("Recovery Words") + .foregroundStyle(.white) + .font(.callout) + .fontWeight(.semibold) } } - .navigationTitle("Secret Words") - .navigationBarTitleDisplayMode(.inline) + .background( + Image(.newWalletPattern) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: screenHeight * 0.75, alignment: .topTrailing) + .frame(maxWidth: .infinity) + .opacity(0.5) + ) + .background(Color.midnightBlue) + .tint(.white) } } diff --git a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift index c1844f6c..4d24e9f5 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift @@ -210,7 +210,7 @@ struct SelectedWalletScreen: View { .ignoresSafeArea(edges: .top) .onChange(of: model.walletMetadata.discoveryState) { _, - newValue in setSheetState(newValue) + newValue in setSheetState(newValue) } .onAppear { setSheetState(model.walletMetadata.discoveryState) } .onAppear(perform: model.validateMetadata) @@ -239,7 +239,7 @@ struct VerifyReminder: View { .foregroundStyle(.red.opacity(0.85)) .fontWeight(.semibold) - Text("backup wallet") + Text("backup your wallet") .fontWeight(.semibold) .font(.caption) From 7318ca9b4190a554ea4db75358e754737bc658bb Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 5 Dec 2024 13:34:25 -0600 Subject: [PATCH 12/15] Allow resetting to a nested route --- ios/Cove/Cove.swift | 134 +++++++++++++++++++++++---- ios/Cove/Extention/General+Ext.swift | 6 ++ ios/Cove/LoadAndResetView.swift | 4 +- ios/Cove/MainViewModel.swift | 10 +- rust/src/app.rs | 19 +++- rust/src/app/reconcile.rs | 2 +- rust/src/router.rs | 29 +++++- 7 files changed, 174 insertions(+), 30 deletions(-) diff --git a/ios/Cove/Cove.swift b/ios/Cove/Cove.swift index 5192cda5..f61f4ab7 100644 --- a/ios/Cove/Cove.swift +++ b/ios/Cove/Cove.swift @@ -3900,10 +3900,15 @@ public protocol FfiAppProtocol : AnyObject { */ func resetDefaultRouteTo(route: Route) + /** + * Reset the default route, with a nested route + */ + func resetNestedRoutesTo(defaultRoute: Route, nestedRoutes: [Route]) + /** * Select a wallet */ - func selectWallet(id: WalletId) throws + func selectWallet(id: WalletId, nextRoute: Route?) throws func state() -> AppState @@ -4111,14 +4116,26 @@ open func resetDefaultRouteTo(route: Route) {try! rustCall() { FfiConverterTypeRoute.lower(route),$0 ) } +} + + /** + * Reset the default route, with a nested route + */ +open func resetNestedRoutesTo(defaultRoute: Route, nestedRoutes: [Route]) {try! rustCall() { + uniffi_cove_fn_method_ffiapp_reset_nested_routes_to(self.uniffiClonePointer(), + FfiConverterTypeRoute.lower(defaultRoute), + FfiConverterSequenceTypeRoute.lower(nestedRoutes),$0 + ) +} } /** * Select a wallet */ -open func selectWallet(id: WalletId)throws {try rustCallWithError(FfiConverterTypeDatabaseError.lift) { +open func selectWallet(id: WalletId, nextRoute: Route? = nil)throws {try rustCallWithError(FfiConverterTypeDatabaseError.lift) { uniffi_cove_fn_method_ffiapp_select_wallet(self.uniffiClonePointer(), - FfiConverterTypeWalletId.lower(id),$0 + FfiConverterTypeWalletId.lower(id), + FfiConverterOptionTypeRoute.lower(nextRoute),$0 ) } } @@ -6821,6 +6838,8 @@ public protocol RouteFactoryProtocol : AnyObject { func isSameParentRoute(route: Route, routeToCheck: Route) -> Bool + func loadAndResetNestedTo(defaultRoute: Route, nestedRoutes: [Route]) -> Route + func loadAndResetTo(resetTo: Route) -> Route func loadAndResetToAfter(resetTo: Route, time: UInt32) -> Route @@ -6932,6 +6951,15 @@ open func isSameParentRoute(route: Route, routeToCheck: Route) -> Bool { }) } +open func loadAndResetNestedTo(defaultRoute: Route, nestedRoutes: [Route]) -> Route { + return try! FfiConverterTypeRoute.lift(try! rustCall() { + uniffi_cove_fn_method_routefactory_load_and_reset_nested_to(self.uniffiClonePointer(), + FfiConverterTypeRoute.lower(defaultRoute), + FfiConverterSequenceTypeRoute.lower(nestedRoutes),$0 + ) +}) +} + open func loadAndResetTo(resetTo: Route) -> Route { return try! FfiConverterTypeRoute.lift(try! rustCall() { uniffi_cove_fn_method_routefactory_load_and_reset_to(self.uniffiClonePointer(), @@ -12512,7 +12540,7 @@ extension AppError: Foundation.LocalizedError { public enum AppStateReconcileMessage { - case defaultRouteChanged(Route + case defaultRouteChanged(Route,[Route] ) case routeUpdated([Route] ) @@ -12538,7 +12566,7 @@ public struct FfiConverterTypeAppStateReconcileMessage: FfiConverterRustBuffer { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .defaultRouteChanged(try FfiConverterTypeRoute.read(from: &buf) + case 1: return .defaultRouteChanged(try FfiConverterTypeRoute.read(from: &buf), try FfiConverterSequenceTypeRoute.read(from: &buf) ) case 2: return .routeUpdated(try FfiConverterSequenceTypeRoute.read(from: &buf) @@ -12566,9 +12594,10 @@ public struct FfiConverterTypeAppStateReconcileMessage: FfiConverterRustBuffer { switch value { - case let .defaultRouteChanged(v1): + case let .defaultRouteChanged(v1,v2): writeInt(&buf, Int32(1)) FfiConverterTypeRoute.write(v1, into: &buf) + FfiConverterSequenceTypeRoute.write(v2, into: &buf) case let .routeUpdated(v1): @@ -16513,11 +16542,13 @@ extension ResumeError: Foundation.LocalizedError { public enum Route { - case loadAndReset(resetTo: BoxedRoute, afterMillis: UInt32 + case loadAndReset(resetTo: [BoxedRoute], afterMillis: UInt32 ) case listWallets case selectedWallet(WalletId ) + case walletSettings(WalletId + ) case newWallet(NewWalletRoute ) case settings @@ -16540,7 +16571,7 @@ public struct FfiConverterTypeRoute: FfiConverterRustBuffer { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .loadAndReset(resetTo: try FfiConverterTypeBoxedRoute.read(from: &buf), afterMillis: try FfiConverterUInt32.read(from: &buf) + case 1: return .loadAndReset(resetTo: try FfiConverterSequenceTypeBoxedRoute.read(from: &buf), afterMillis: try FfiConverterUInt32.read(from: &buf) ) case 2: return .listWallets @@ -16548,18 +16579,21 @@ public struct FfiConverterTypeRoute: FfiConverterRustBuffer { case 3: return .selectedWallet(try FfiConverterTypeWalletId.read(from: &buf) ) - case 4: return .newWallet(try FfiConverterTypeNewWalletRoute.read(from: &buf) + case 4: return .walletSettings(try FfiConverterTypeWalletId.read(from: &buf) ) - case 5: return .settings + case 5: return .newWallet(try FfiConverterTypeNewWalletRoute.read(from: &buf) + ) + + case 6: return .settings - case 6: return .secretWords(try FfiConverterTypeWalletId.read(from: &buf) + case 7: return .secretWords(try FfiConverterTypeWalletId.read(from: &buf) ) - case 7: return .transactionDetails(id: try FfiConverterTypeWalletId.read(from: &buf), details: try FfiConverterTypeTransactionDetails.read(from: &buf) + case 8: return .transactionDetails(id: try FfiConverterTypeWalletId.read(from: &buf), details: try FfiConverterTypeTransactionDetails.read(from: &buf) ) - case 8: return .send(try FfiConverterTypeSendRoute.read(from: &buf) + case 9: return .send(try FfiConverterTypeSendRoute.read(from: &buf) ) default: throw UniffiInternalError.unexpectedEnumCase @@ -16572,7 +16606,7 @@ public struct FfiConverterTypeRoute: FfiConverterRustBuffer { case let .loadAndReset(resetTo,afterMillis): writeInt(&buf, Int32(1)) - FfiConverterTypeBoxedRoute.write(resetTo, into: &buf) + FfiConverterSequenceTypeBoxedRoute.write(resetTo, into: &buf) FfiConverterUInt32.write(afterMillis, into: &buf) @@ -16585,28 +16619,33 @@ public struct FfiConverterTypeRoute: FfiConverterRustBuffer { FfiConverterTypeWalletId.write(v1, into: &buf) - case let .newWallet(v1): + case let .walletSettings(v1): writeInt(&buf, Int32(4)) + FfiConverterTypeWalletId.write(v1, into: &buf) + + + case let .newWallet(v1): + writeInt(&buf, Int32(5)) FfiConverterTypeNewWalletRoute.write(v1, into: &buf) case .settings: - writeInt(&buf, Int32(5)) + writeInt(&buf, Int32(6)) case let .secretWords(v1): - writeInt(&buf, Int32(6)) + writeInt(&buf, Int32(7)) FfiConverterTypeWalletId.write(v1, into: &buf) case let .transactionDetails(id,details): - writeInt(&buf, Int32(7)) + writeInt(&buf, Int32(8)) FfiConverterTypeWalletId.write(id, into: &buf) FfiConverterTypeTransactionDetails.write(details, into: &buf) case let .send(v1): - writeInt(&buf, Int32(8)) + writeInt(&buf, Int32(9)) FfiConverterTypeSendRoute.write(v1, into: &buf) } @@ -20132,6 +20171,30 @@ fileprivate struct FfiConverterOptionTypeNumberOfBip39Words: FfiConverterRustBuf } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeRoute: FfiConverterRustBuffer { + typealias SwiftType = Route? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeRoute.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeRoute.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -20205,6 +20268,31 @@ fileprivate struct FfiConverterSequenceString: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeBoxedRoute: FfiConverterRustBuffer { + typealias SwiftType = [BoxedRoute] + + public static func write(_ value: [BoxedRoute], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeBoxedRoute.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [BoxedRoute] { + let len: Int32 = try readInt(&buf) + var seq = [BoxedRoute]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeBoxedRoute.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -21284,7 +21372,10 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_ffiapp_reset_default_route_to() != 40613) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_ffiapp_select_wallet() != 4478) { + if (uniffi_cove_checksum_method_ffiapp_reset_nested_routes_to() != 13093) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_ffiapp_select_wallet() != 31318) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_ffiapp_state() != 19551) { @@ -21449,6 +21540,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_routefactory_is_same_parent_route() != 43168) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_routefactory_load_and_reset_nested_to() != 36095) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_routefactory_load_and_reset_to() != 41201) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/Cove/Extention/General+Ext.swift b/ios/Cove/Extention/General+Ext.swift index b325da20..9958101e 100644 --- a/ios/Cove/Extention/General+Ext.swift +++ b/ios/Cove/Extention/General+Ext.swift @@ -96,6 +96,12 @@ extension UnsignedTransaction: Identifiable { } } +extension [BoxedRoute] { + var routes: [Route] { + self.map { $0.route() } + } +} + #if canImport(UIKit) extension View { func hideKeyboard() { diff --git a/ios/Cove/LoadAndResetView.swift b/ios/Cove/LoadAndResetView.swift index 5cce6c2b..aef5bb3e 100644 --- a/ios/Cove/LoadAndResetView.swift +++ b/ios/Cove/LoadAndResetView.swift @@ -8,7 +8,7 @@ import SwiftUI struct LoadAndResetView: View { @Environment(MainViewModel.self) var app - let nextRoute: Route + let nextRoute: [Route] let loadingTimeMs: Int var body: some View { @@ -22,5 +22,5 @@ struct LoadAndResetView: View { } #Preview { - LoadAndResetView(nextRoute: .listWallets, loadingTimeMs: 100).environment(MainViewModel()) + LoadAndResetView(nextRoute: [.listWallets], loadingTimeMs: 100).environment(MainViewModel()) } diff --git a/ios/Cove/MainViewModel.swift b/ios/Cove/MainViewModel.swift index bcc39271..64aad1b0 100644 --- a/ios/Cove/MainViewModel.swift +++ b/ios/Cove/MainViewModel.swift @@ -106,6 +106,11 @@ import SwiftUI } @MainActor + func resetRoute(to routes: [Route]) { + guard routes.count > 1 else { return resetRoute(to: routes[0]) } + rust.resetNestedRoutesTo(defaultRoute: routes[0], nestedRoutes: Array(routes[1...])) + } + func resetRoute(to route: Route) { rust.resetDefaultRouteTo(route: route) } @@ -133,9 +138,8 @@ import SwiftUI case let .selectedNodeChanged(node): self.selectedNode = node - case let .defaultRouteChanged(route): - // default changes, means root changes, set routes to [] - self.router.routes = [] + case let .defaultRouteChanged(route, nestedRoutes): + self.router.routes = nestedRoutes self.router.default = route self.routeId = UUID() diff --git a/rust/src/app.rs b/rust/src/app.rs index d11ab8d3..4154d765 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -10,7 +10,7 @@ use crate::{ fiat::client::{PriceResponse, FIAT_CLIENT}, network::Network, node::Node, - router::{Route, Router}, + router::{Route, RouteFactory, Router}, transaction::fees::client::{FeeResponse, FEE_CLIENT}, wallet::metadata::WalletId, }; @@ -260,6 +260,21 @@ impl FfiApp { self.reset_default_route_to(loading_route); } + // MARK: Routes + /// Reset the default route, with a nested route + pub fn reset_nested_routes_to(&self, default_route: Route, nested_routes: Vec) { + self.inner() + .state + .write() + .router + .reset_nested_routes_to(default_route.clone(), nested_routes.clone()); + + Updater::send_update(AppStateReconcileMessage::DefaultRouteChanged( + default_route, + nested_routes, + )); + } + /// Change the default route, and reset the routes pub fn reset_default_route_to(&self, route: Route) { debug!("changing default route to: {:?}", route); @@ -281,7 +296,7 @@ impl FfiApp { .router .reset_routes_to(route.clone()); - Updater::send_update(AppStateReconcileMessage::DefaultRouteChanged(route)); + Updater::send_update(AppStateReconcileMessage::DefaultRouteChanged(route, vec![])); } pub fn state(&self) -> AppState { diff --git a/rust/src/app/reconcile.rs b/rust/src/app/reconcile.rs index 9b51dba2..94dd62ff 100644 --- a/rust/src/app/reconcile.rs +++ b/rust/src/app/reconcile.rs @@ -11,7 +11,7 @@ use crate::{ #[derive(uniffi::Enum)] #[allow(clippy::enum_variant_names)] pub enum AppStateReconcileMessage { - DefaultRouteChanged(Route), + DefaultRouteChanged(Route, Vec), RouteUpdated(Vec), DatabaseUpdated, ColorSchemeChanged(ColorSchemeSelection), diff --git a/rust/src/router.rs b/rust/src/router.rs index badb8372..46fdcc35 100644 --- a/rust/src/router.rs +++ b/rust/src/router.rs @@ -14,11 +14,12 @@ use macros::impl_default_for; #[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] pub enum Route { LoadAndReset { - reset_to: Arc, + reset_to: Vec>, after_millis: u32, }, ListWallets, SelectedWallet(WalletId), + WalletSettings(WalletId), NewWallet(NewWalletRoute), Settings, SecretWords(WalletId), @@ -111,6 +112,13 @@ impl Router { self.default = route; self.routes.clear(); } + + pub fn reset_nested_routes_to(&mut self, default: Route, nested_routes: Vec) { + self.default = default; + + self.routes.clear(); + self.routes = nested_routes; + } } #[derive( @@ -154,7 +162,7 @@ impl Route { pub fn load_and_reset_after(self, time: u32) -> Self { Self::LoadAndReset { - reset_to: BoxedRoute::new(self).into(), + reset_to: vec![BoxedRoute::new(self).into()], after_millis: time, } } @@ -216,6 +224,23 @@ impl RouteFactory { ColdWalletRoute::QrCode.into() } + pub fn load_and_reset_nested_to( + &self, + default_route: Route, + nested_routes: Vec, + ) -> Route { + let boxed_nested_routes = nested_routes.into_iter().map(BoxedRoute::new).map(Arc::new); + + let mut routes = Vec::with_capacity(boxed_nested_routes.len() + 1); + routes.push(BoxedRoute::new(default_route).into()); + routes.extend(boxed_nested_routes); + + Route::LoadAndReset { + reset_to: routes, + after_millis: 500, + } + } + pub fn load_and_reset_to(&self, reset_to: Route) -> Route { Self::load_and_reset_to_after(self, reset_to, 500) } From c71d9f6315eedf968d979f3309f587a95a8495f7 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 5 Dec 2024 13:34:48 -0600 Subject: [PATCH 13/15] Long press on sidebar to access wallet settings --- .../HotWallet/HotWalletCreateScreen.swift | 14 +++--- .../SelectedWalletScreen.swift | 2 +- ios/Cove/RouteView.swift | 7 ++- ios/Cove/Views/SidebarView.swift | 21 ++++++++- ios/Cove/WalletSettingsContainer.swift | 45 +++++++++++++++++++ rust/src/app.rs | 16 ++++++- rust/src/word_validator.rs | 13 +++--- 7 files changed, 100 insertions(+), 18 deletions(-) create mode 100644 ios/Cove/WalletSettingsContainer.swift diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift index 219359d8..4a86fc34 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift @@ -62,12 +62,14 @@ struct WordsView: View { } HStack { - Text("Your secret recovery words are the only way to recover your wallet if you lose your phone or switch to a different wallet.") - .font(.subheadline) - .foregroundStyle(.lightGray) - .multilineTextAlignment(.leading) - .opacity(0.70) - .fixedSize(horizontal: false, vertical: true) + Text( + "Your secret recovery words are the only way to recover your wallet if you lose your phone or switch to a different wallet. Whoever, has you recovery words, controls your Bitcoin." + ) + .font(.subheadline) + .foregroundStyle(.lightGray) + .multilineTextAlignment(.leading) + .opacity(0.70) + .fixedSize(horizontal: false, vertical: true) Spacer() } diff --git a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift index 4d24e9f5..19846a69 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift @@ -210,7 +210,7 @@ struct SelectedWalletScreen: View { .ignoresSafeArea(edges: .top) .onChange(of: model.walletMetadata.discoveryState) { _, - newValue in setSheetState(newValue) + newValue in setSheetState(newValue) } .onAppear { setSheetState(model.walletMetadata.discoveryState) } .onAppear(perform: model.validateMetadata) diff --git a/ios/Cove/RouteView.swift b/ios/Cove/RouteView.swift index 720e60d4..a5cca68b 100644 --- a/ios/Cove/RouteView.swift +++ b/ios/Cove/RouteView.swift @@ -37,8 +37,11 @@ struct RouteView: View { @MainActor @ViewBuilder func routeToView(model: MainViewModel, route: Route) -> some View { switch route { - case let .loadAndReset(resetTo: route, afterMillis: time): - LoadAndResetView(nextRoute: route.route(), loadingTimeMs: Int(time)) + case let .loadAndReset(resetTo: routes, afterMillis: time): + LoadAndResetView(nextRoute: routes.routes, loadingTimeMs: Int(time)) + case let .walletSettings(id): + WalletSettingsContainer(id: id) + .environment(model) case .settings: SettingsScreen() case .listWallets: diff --git a/ios/Cove/Views/SidebarView.swift b/ios/Cove/Views/SidebarView.swift index 8347126e..71909888 100644 --- a/ios/Cove/Views/SidebarView.swift +++ b/ios/Cove/Views/SidebarView.swift @@ -106,6 +106,25 @@ struct SidebarView: View { .padding() .background(Color.lightGray.opacity(0.06)) .cornerRadius(10) + .contentShape( + .contextMenuPreview, + RoundedRectangle(cornerRadius: 10) + ) + .contextMenu { + Button("Settings") { + app.isSidebarVisible = false + + do { + try app.rust.selectWallet( + id: wallet.id, + nextRoute: Route.walletSettings(wallet.id) + ) + } catch { + Log.error("Failed to select wallet \(error)") + goTo(Route.selectedWallet(wallet.id)) + } + } + } } } @@ -160,7 +179,7 @@ struct SidebarView: View { } if !app.hasWallets, route == Route.newWallet(.select) { - return app.resetRoute(to: RouteFactory().newWalletSelect()) + return app.resetRoute(to: [RouteFactory().newWalletSelect()]) } navigate(route) diff --git a/ios/Cove/WalletSettingsContainer.swift b/ios/Cove/WalletSettingsContainer.swift new file mode 100644 index 00000000..d1b823d4 --- /dev/null +++ b/ios/Cove/WalletSettingsContainer.swift @@ -0,0 +1,45 @@ +// +// WalletSettingsContainer.swift +// Cove +// +// Created by Praveen Perera on 12/5/24. +// + +import Foundation +import SwiftUI + +struct WalletSettingsContainer: View { + @Environment(MainViewModel.self) var app + + // args + let id: WalletId + + // private + @State private var model: WalletViewModel? = nil + @State private var error: String? = nil + + func initOnAppear() { + do { + let model = try app.getWalletViewModel(id: self.id) + self.model = model + } catch { + self.error = "Failed to get wallet \(error.localizedDescription)" + Log.error(self.error!) + } + } + + var body: some View { + if let model { + WalletSettingsSheet(model: model) + } else { + Text(self.error ?? "Loading...") + .task { + guard let error = self.error else { return } + Log.error(error) + try? await Task.sleep(for: .seconds(5)) + self.app.resetRoute(to: .listWallets) + } + .onAppear(perform: self.initOnAppear) + } + } +} diff --git a/rust/src/app.rs b/rust/src/app.rs index 4154d765..7c4c7900 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -217,12 +217,24 @@ impl FfiApp { } /// Select a wallet - pub fn select_wallet(&self, id: WalletId) -> Result<(), DatabaseError> { + #[uniffi::method(default(next_route = None))] + pub fn select_wallet( + &self, + id: WalletId, + next_route: Option, + ) -> Result<(), DatabaseError> { // set the selected wallet Database::global().global_config.select_wallet(id.clone())?; // update the router - self.go_to_selected_wallet(); + if let Some(next_route) = next_route { + let wallet_route = Route::SelectedWallet(id.clone()); + let loading_route = + RouteFactory.load_and_reset_nested_to(wallet_route, vec![next_route]); + self.load_and_reset_default_route(loading_route); + } else { + self.go_to_selected_wallet(); + } Ok(()) } diff --git a/rust/src/word_validator.rs b/rust/src/word_validator.rs index 0438048c..a5826255 100644 --- a/rust/src/word_validator.rs +++ b/rust/src/word_validator.rs @@ -39,13 +39,14 @@ impl WordValidator { let five_existing_words = words_clone.iter().take(5).cloned(); let six_new_words = new_words.words().take(6); - let correct = std::iter::once(correct_word); - let mut combined: Vec = five_existing_words - .chain(six_new_words) - .chain(correct) - .map(|word| word.to_string()) - .collect(); + let mut combined: Vec = Vec::with_capacity(12); + combined.push(correct_word.to_string()); + combined.extend( + five_existing_words + .chain(six_new_words) + .map(ToString::to_string), + ); // remove last word from the list if word_index > 0 { From 7d667711ba2d205167081fdd80a84e11c6106322 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 5 Dec 2024 13:49:09 -0600 Subject: [PATCH 14/15] Don't show toolbar when wallet settings is not sheet --- ios/Cove/CoveApp.swift | 2 +- ios/Cove/WalletSettingsContainer.swift | 2 +- ios/Cove/WalletSettingsSheet.swift | 37 +++++++++++++++----------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/ios/Cove/CoveApp.swift b/ios/Cove/CoveApp.swift index 6fe798b9..917cbdb1 100644 --- a/ios/Cove/CoveApp.swift +++ b/ios/Cove/CoveApp.swift @@ -378,7 +378,7 @@ struct CoveApp: App { var routeToTint: Color { switch model.router.routes.last { - case .settings: + case .settings, .walletSettings: .blue default: .white diff --git a/ios/Cove/WalletSettingsContainer.swift b/ios/Cove/WalletSettingsContainer.swift index d1b823d4..fad7d4df 100644 --- a/ios/Cove/WalletSettingsContainer.swift +++ b/ios/Cove/WalletSettingsContainer.swift @@ -30,7 +30,7 @@ struct WalletSettingsContainer: View { var body: some View { if let model { - WalletSettingsSheet(model: model) + WalletSettingsSheet(model: model, isSheet: false) } else { Text(self.error ?? "Loading...") .task { diff --git a/ios/Cove/WalletSettingsSheet.swift b/ios/Cove/WalletSettingsSheet.swift index eea04542..efe2b77e 100644 --- a/ios/Cove/WalletSettingsSheet.swift +++ b/ios/Cove/WalletSettingsSheet.swift @@ -5,6 +5,9 @@ struct WalletSettingsSheet: View { @Environment(\.navigate) private var navigate @Environment(\.dismiss) private var dismiss + // args + @State var isSheet = true + @State private var showingDeleteConfirmation = false @State private var showingSecretWordsConfirmation = false @@ -95,22 +98,26 @@ struct WalletSettingsSheet: View { } .listStyle(InsetGroupedListStyle()) .navigationTitle("Wallet Settings") - .navigationBarItems( - leading: - Button { - dismiss() - navigate(Route.settings) - } label: { - Label("App Settings", systemImage: "gear") - .foregroundColor(.blue) - } - ) - .navigationBarItems( - trailing: Button("Done") { - dismiss() - model.validateMetadata() + .toolbar { + if isSheet { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + navigate(Route.settings) + } label: { + Label("App Settings", systemImage: "gear") + .foregroundColor(.blue) + } + } + + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + model.validateMetadata() + } + } } - ) + } .foregroundColor(.primary) .confirmationDialog("Are you sure?", isPresented: $showingDeleteConfirmation) { Button("Delete", role: .destructive) { From d658a4ea2f14b3def7b1b5fcba42df0e30a3afe2 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 5 Dec 2024 13:50:02 -0600 Subject: [PATCH 15/15] Run swiftformat --- ios/Cove/WalletSettingsContainer.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ios/Cove/WalletSettingsContainer.swift b/ios/Cove/WalletSettingsContainer.swift index fad7d4df..310bd8ff 100644 --- a/ios/Cove/WalletSettingsContainer.swift +++ b/ios/Cove/WalletSettingsContainer.swift @@ -20,7 +20,7 @@ struct WalletSettingsContainer: View { func initOnAppear() { do { - let model = try app.getWalletViewModel(id: self.id) + let model = try app.getWalletViewModel(id: id) self.model = model } catch { self.error = "Failed to get wallet \(error.localizedDescription)" @@ -32,14 +32,14 @@ struct WalletSettingsContainer: View { if let model { WalletSettingsSheet(model: model, isSheet: false) } else { - Text(self.error ?? "Loading...") + Text(error ?? "Loading...") .task { - guard let error = self.error else { return } + guard let error else { return } Log.error(error) try? await Task.sleep(for: .seconds(5)) - self.app.resetRoute(to: .listWallets) + app.resetRoute(to: .listWallets) } - .onAppear(perform: self.initOnAppear) + .onAppear(perform: initOnAppear) } } }