diff --git a/Sources/Core/DesignSystem/Components/AppTextField.swift b/Sources/Core/DesignSystem/Components/AppTextField.swift index 42de67a169..627cdd5604 100644 --- a/Sources/Core/DesignSystem/Components/AppTextField.swift +++ b/Sources/Core/DesignSystem/Components/AppTextField.swift @@ -18,7 +18,20 @@ public struct AppTextField @@ -29,7 +42,7 @@ public struct AppTextField, @@ -51,7 +64,7 @@ public struct AppTextField, @@ -76,9 +89,9 @@ public struct AppTextField> diff --git a/Sources/Features/AppFeature/App+Reducer.swift b/Sources/Features/AppFeature/App+Reducer.swift index 1b1d234989..9601a2ec61 100644 --- a/Sources/Features/AppFeature/App+Reducer.swift +++ b/Sources/Features/AppFeature/App+Reducer.swift @@ -69,6 +69,7 @@ public struct App: Sendable, FeatureReducer { } } + @Dependency(\.continuousClock) var clock @Dependency(\.errorQueue) var errorQueue @Dependency(\.deviceFactorSourceClient) var deviceFactorSourceClient @Dependency(\.appPreferencesClient) var appPreferencesClient @@ -114,7 +115,7 @@ public struct App: Sendable, FeatureReducer { if error is Profile.ProfileIsUsedOnAnotherDeviceError { await send(.internal(.toOnboarding)) // A slight delay to allow any modal that may be shown to be dismissed. - try? await Task.sleep(for: .seconds(0.5)) + try? await clock.sleep(for: .seconds(0.5)) } await send(.internal(.displayErrorAlert(UserFacingError(error)))) } diff --git a/Sources/Features/EditPersonaFeature/EditPersonaField+View.swift b/Sources/Features/EditPersonaFeature/EditPersonaField+View.swift index 8fe8e85a16..d6c6fccf4b 100644 --- a/Sources/Features/EditPersonaFeature/EditPersonaField+View.swift +++ b/Sources/Features/EditPersonaFeature/EditPersonaField+View.swift @@ -53,7 +53,7 @@ extension EditPersonaField { public var body: some SwiftUI.View { WithViewStore(store, observe: ViewState.init(state:), send: { .view($0) }) { viewStore in AppTextField( - primaryHeading: viewStore.primaryHeading, + primaryHeading: .init(text: viewStore.primaryHeading), secondaryHeading: viewStore.secondaryHeading, placeholder: "", text: viewStore.validation( diff --git a/Sources/Features/ImportMnemonic/ImportMnemonic+View.swift b/Sources/Features/ImportMnemonic/ImportMnemonic+View.swift index 669e0cba18..acee291abd 100644 --- a/Sources/Features/ImportMnemonic/ImportMnemonic+View.swift +++ b/Sources/Features/ImportMnemonic/ImportMnemonic+View.swift @@ -10,9 +10,7 @@ extension ImportMnemonic.State { isReadonlyMode: isReadonlyMode, isHidingSecrets: isHidingSecrets, rowCount: rowCount, - wordCount: wordCount.rawValue, - isAddRowButtonEnabled: isAddRowButtonEnabled, - isRemoveRowButtonEnabled: isRemoveRowButtonEnabled, + wordCount: wordCount, completedWords: completedWords, mnemonic: mnemonic, bip39Passphrase: bip39Passphrase @@ -24,20 +22,43 @@ extension ImportMnemonic.State { } } -// MARK: - ImportMnemonic.View +// MARK: - ImportMnemonic.ViewState extension ImportMnemonic { public struct ViewState: Equatable { let isReadonlyMode: Bool let isHidingSecrets: Bool let rowCount: Int - let wordCount: Int - let isAddRowButtonEnabled: Bool - let isRemoveRowButtonEnabled: Bool + let wordCount: BIP39.WordCount let completedWords: [BIP39.Word] let mnemonic: Mnemonic? let bip39Passphrase: String } +} + +extension ImportMnemonic.ViewState { + var isNonChecksummed: Bool { + mnemonic == nil && completedWords.count == wordCount.rawValue + } + + var isAddRowButtonEnabled: Bool { + wordCount != .twentyFour + } + + var isRemoveRowButtonEnabled: Bool { + wordCount != .twelve + } + + var isShowingPassphrase: Bool { + !(isReadonlyMode && bip39Passphrase.isEmpty) + } + + var isShowingChangeWordCountButtons: Bool { + !isReadonlyMode + } +} +// MARK: - ImportMnemonic.View +extension ImportMnemonic { @MainActor public struct View: SwiftUI.View { @Environment(\.scenePhase) var scenePhase @@ -51,70 +72,14 @@ extension ImportMnemonic { WithViewStore(store, observe: \.viewState, send: { .view($0) }) { viewStore in ScrollView(showsIndicators: false) { VStack(spacing: .large1) { - LazyVGrid( - columns: .init( - repeating: .init(.flexible()), - count: 3 - ) - ) { - ForEachStore( - store.scope(state: \.words, action: { .child(.word(id: $0, child: $1)) }), - content: { importMnemonicWordStore in - VStack(spacing: 0) { - ImportMnemonicWord.View(store: importMnemonicWordStore) - Spacer(minLength: .medium2) - } - } - ) - } + wordsGrid(with: viewStore) - if !viewStore.isReadonlyMode { - HStack { - Button { - viewStore.send(.removeRowButtonTapped) - } label: { - // FIXME: strings - HStack { - Text("Less words") - .foregroundColor(viewStore.isRemoveRowButtonEnabled ? .app.gray1 : .app.white) - Image(systemName: "text.badge.plus") - .foregroundColor(viewStore.isRemoveRowButtonEnabled ? .app.red1 : .app.white) - } - } - .controlState(viewStore.isRemoveRowButtonEnabled ? .enabled : .disabled) - - Spacer(minLength: 0) - - Button { - viewStore.send(.addRowButtonTapped) - } label: { - // FIXME: strings - HStack { - Text("More words") - .foregroundColor(viewStore.isAddRowButtonEnabled ? .app.gray1 : .app.white) - Image(systemName: "text.badge.plus") - .foregroundColor(viewStore.isAddRowButtonEnabled ? .app.green1 : .app.white) - } - } - .controlState(viewStore.isAddRowButtonEnabled ? .enabled : .disabled) - } - .buttonStyle(.secondaryRectangular) + if viewStore.isShowingChangeWordCountButtons { + changeWordCountButtons(with: viewStore) } - if !(viewStore.isReadonlyMode && viewStore.bip39Passphrase.isEmpty) { - AppTextField( - // FIXME: strings - primaryHeading: "Passhprase", - placeholder: "Passphrase", - text: viewStore.binding( - get: \.bip39Passphrase, - send: { .passphraseChanged($0) } - ), - // FIXME: strings - hint: viewStore.isReadonlyMode ? nil : .info("BIP39 Passphrase is often called a '25th word'.") - ) - .disabled(viewStore.isReadonlyMode) - .autocorrectionDisabled() + if viewStore.isShowingPassphrase { + passphrase(with: viewStore) } } .redacted(reason: .privacy, if: viewStore.isHidingSecrets) @@ -122,27 +87,7 @@ extension ImportMnemonic { viewStore.send(.scenePhase(newPhase)) } .footer { - WithControlRequirements( - viewStore.mnemonic, - forAction: { viewStore.send(.continueButtonTapped($0)) } - ) { action in - if !viewStore.isReadonlyMode { - if viewStore.mnemonic == nil, viewStore.completedWords.count == viewStore.wordCount { - // FIXME: strings - Text("Mnemonic not checksummed") - .foregroundColor(.app.red1) - } - // FIXME: strings - Button("Import mnemonic", action: action) - .buttonStyle(.primaryRectangular) - } else { - // FIXME: strings - Button("Done") { - viewStore.send(.doneViewing) - } - .buttonStyle(.primaryRectangular) - } - } + footer(with: viewStore) } } .animation(.default, value: viewStore.wordCount) @@ -156,6 +101,104 @@ extension ImportMnemonic { } } +extension ImportMnemonic.View { + @ViewBuilder + private func wordsGrid(with viewStore: ViewStoreOf) -> some SwiftUI.View { + LazyVGrid( + columns: .init( + repeating: .init(.flexible()), + count: 3 + ) + ) { + ForEachStore( + store.scope(state: \.words, action: { .child(.word(id: $0, child: $1)) }), + content: { importMnemonicWordStore in + VStack(spacing: 0) { + ImportMnemonicWord.View(store: importMnemonicWordStore) + Spacer(minLength: .medium2) + } + } + ) + } + } + + @ViewBuilder + private func passphrase(with viewStore: ViewStoreOf) -> some SwiftUI.View { + AppTextField( + // FIXME: strings + primaryHeading: .init(text: "Passhprase", isProminent: false), + placeholder: "Passphrase", + text: viewStore.binding( + get: \.bip39Passphrase, + send: { .passphraseChanged($0) } + ), + // FIXME: strings + hint: viewStore.isReadonlyMode ? nil : .info("BIP39 Passphrase is often called a '25th word'.") + ) + .disabled(viewStore.isReadonlyMode) + .autocorrectionDisabled() + } + + @ViewBuilder + private func changeWordCountButtons(with viewStore: ViewStoreOf) -> some SwiftUI.View { + HStack { + Button { + viewStore.send(.removeRowButtonTapped) + } label: { + // FIXME: strings + HStack { + Text("Less words") + .foregroundColor(viewStore.isRemoveRowButtonEnabled ? .app.gray1 : .app.white) + Image(systemName: "text.badge.plus") + .foregroundColor(viewStore.isRemoveRowButtonEnabled ? .app.red1 : .app.white) + } + } + .controlState(viewStore.isRemoveRowButtonEnabled ? .enabled : .disabled) + + Spacer(minLength: 0) + + Button { + viewStore.send(.addRowButtonTapped) + } label: { + // FIXME: strings + HStack { + Text("More words") + .foregroundColor(viewStore.isAddRowButtonEnabled ? .app.gray1 : .app.white) + Image(systemName: "text.badge.plus") + .foregroundColor(viewStore.isAddRowButtonEnabled ? .app.green1 : .app.white) + } + } + .controlState(viewStore.isAddRowButtonEnabled ? .enabled : .disabled) + } + .buttonStyle(.secondaryRectangular) + } + + @ViewBuilder + private func footer(with viewStore: ViewStoreOf) -> some SwiftUI.View { + WithControlRequirements( + viewStore.mnemonic, + forAction: { viewStore.send(.continueButtonTapped($0)) } + ) { action in + if !viewStore.isReadonlyMode { + if viewStore.isNonChecksummed { + // FIXME: strings + Text("Mnemonic not checksummed") + .foregroundColor(.app.red1) + } + // FIXME: strings + Button("Import mnemonic", action: action) + .buttonStyle(.primaryRectangular) + } else { + // FIXME: strings + Button("Done") { + viewStore.send(.doneViewing) + } + .buttonStyle(.primaryRectangular) + } + } + } +} + extension View { /// Conditionally adds a reason to apply a redaction to this view hierarchy. /// diff --git a/Sources/Features/ImportMnemonic/ImportMnemonic.swift b/Sources/Features/ImportMnemonic/ImportMnemonic.swift index e3fc79e62a..6adcc89d9b 100644 --- a/Sources/Features/ImportMnemonic/ImportMnemonic.swift +++ b/Sources/Features/ImportMnemonic/ImportMnemonic.swift @@ -15,37 +15,45 @@ public struct ImportMnemonic: Sendable, FeatureReducer { public var language: BIP39.Language public var wordCount: BIP39.WordCount { - willSet { - let delta = newValue.rawValue - wordCount.rawValue - - if delta > 0 { - // is increasing word count - words.append(contentsOf: (wordCount.rawValue ..< newValue.rawValue).map { - .init(id: $0, isReadonlyMode: isReadonlyMode) - }) - } else if delta < 0 { - // is decreasing word count - words.removeLast(abs(delta)) - } - switch newValue { - case .twelve: - self.isRemoveRowButtonEnabled = false - case .fifteen, .eighteen, .twentyOne: - self.isRemoveRowButtonEnabled = true - self.isAddRowButtonEnabled = true - case .twentyFour: - self.isAddRowButtonEnabled = false - } + guard let wordCount = BIP39.WordCount(wordCount: words.count) else { + assertionFailure("Should never happen") + return .twentyFour } + return wordCount } - public var isAddRowButtonEnabled: Bool - public var isRemoveRowButtonEnabled: Bool + public mutating func changeWordCount(by delta: Int) { + let positiveDelta = abs(delta) + precondition(positiveDelta.isMultiple(of: ImportMnemonic.wordsPerRow)) + + let wordCount = words.count + let newWordCount = BIP39.WordCount(wordCount: wordCount + delta)! // might infact be subtraction + if delta > 0 { + // is increasing word count + words.append(contentsOf: (wordCount ..< newWordCount.rawValue).map { + .init( + id: $0, + placeholder: ImportMnemonic.placeholder( + index: $0, + wordCount: newWordCount, + language: language + ), + isReadonlyMode: isReadonlyMode + ) + }) + } else if delta < 0 { + // is decreasing word count + words.removeLast(positiveDelta) + } + } public var bip39Passphrase: String = "" public var mnemonic: Mnemonic? { - try? Mnemonic( + guard completedWords.count == words.count else { + return nil + } + return try? Mnemonic( words: completedWords ) } @@ -69,22 +77,12 @@ public struct ImportMnemonic: Sendable, FeatureReducer { self.saveInProfile = saveInProfile self.language = language - self.wordCount = wordCount self.bip39Passphrase = bip39Passphrase - self.isAddRowButtonEnabled = wordCount != .twentyFour - self.isRemoveRowButtonEnabled = wordCount != .twelve - let isReadonlyMode = false self.isReadonlyMode = isReadonlyMode - self.words = .init( - uncheckedUniqueElements: (0 ..< wordCount.rawValue).map { - ImportMnemonicWord.State( - id: $0, - isReadonlyMode: isReadonlyMode - ) - } - ) + self.words = [] + changeWordCount(by: wordCount.rawValue) } public init( @@ -93,11 +91,8 @@ public struct ImportMnemonic: Sendable, FeatureReducer { let mnemonic = mnemonicWithPassphrase.mnemonic self.saveInProfile = false self.language = mnemonic.language - self.wordCount = mnemonic.wordCount - self.isAddRowButtonEnabled = false let isReadonlyMode = true self.isReadonlyMode = isReadonlyMode - self.isRemoveRowButtonEnabled = false self.words = .init( uniqueElements: mnemonic.words .enumerated() @@ -109,6 +104,11 @@ public struct ImportMnemonic: Sendable, FeatureReducer { word: $0.element, completion: .auto(match: .exact) ), + placeholder: ImportMnemonic.placeholder( + index: $0.offset, + wordCount: mnemonic.wordCount, + language: mnemonic.language + ), isReadonlyMode: isReadonlyMode ) } @@ -205,13 +205,11 @@ public struct ImportMnemonic: Sendable, FeatureReducer { return .none case .addRowButtonTapped: - assert(state.isAddRowButtonEnabled) - state.wordCount.increaseBy3() + state.changeWordCount(by: +ImportMnemonic.wordsPerRow) return .none case .removeRowButtonTapped: - assert(state.isRemoveRowButtonEnabled) - state.wordCount.decreaseBy3() + state.changeWordCount(by: -ImportMnemonic.wordsPerRow) return .none case let .continueButtonTapped(mnemonic): @@ -316,3 +314,37 @@ extension ImportMnemonic { } } } + +extension ImportMnemonic { + static func placeholder( + index: Int, + wordCount: BIP39.WordCount, + language: BIP39.Language + ) -> String { + precondition(index <= 23, "Invalid BIP39 word index, got index: \(index), exected less than 24.") + let word: BIP39.Word = { + let wordList = BIP39.wordList(for: language) + switch language { + case .english: + let bip39Alphabet = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", /* X is missing */ "y", "z"] + return wordList + .words + // we use `last` simply because we did not like the words "abandon baby" + // which we get by using `first`, too sad a combination. + .last( + where: { $0.word.rawValue.hasPrefix(bip39Alphabet[index]) } + )! + + default: + let scale = UInt16(89) // 2048 / 23 + let indexScaled = BIP39.Word.Index(valueBoundBy16Bits: scale * UInt16(index))! + return wordList.indexToWord[indexScaled]! + } + + }() + return word.word.rawValue + } +} + +// MARK: - ScenePhase + Sendable +extension ScenePhase: @unchecked Sendable {} diff --git a/Sources/Features/ImportMnemonic/ImportWord/ImportMnemonicWord+View.swift b/Sources/Features/ImportMnemonic/ImportWord/ImportMnemonicWord+View.swift index c89c9e46d8..7974bf11e2 100644 --- a/Sources/Features/ImportMnemonic/ImportWord/ImportMnemonicWord+View.swift +++ b/Sources/Features/ImportMnemonic/ImportWord/ImportMnemonicWord+View.swift @@ -7,6 +7,7 @@ extension ImportMnemonicWord.State { .init( isReadonlyMode: isReadonlyMode, index: id, + placeholder: placeholder, displayText: value.text, autocompletionCandidates: autocompletionCandidates, focusedField: focusedField, @@ -33,16 +34,12 @@ extension ImportMnemonicWord { public struct ViewState: Equatable { let isReadonlyMode: Bool let index: Int - + let placeholder: String let displayText: String let autocompletionCandidates: ImportMnemonicWord.State.AutocompletionCandidates? let focusedField: State.Field? let validation: Validation? - var wordAtIndex: String { - // FIXME: strings - "word #\(index + 1)" - } var hint: Hint? { guard let validation, validation == .invalid else { @@ -73,8 +70,8 @@ extension ImportMnemonicWord { public var body: some SwiftUI.View { WithViewStore(store, observe: \.viewState, send: { .view($0) }) { viewStore in AppTextField( - secondaryHeading: viewStore.wordAtIndex, - placeholder: viewStore.wordAtIndex, + primaryHeading: .init(text: "word #\(viewStore.index + 1)", isProminent: false), + placeholder: viewStore.placeholder, text: .init( get: { viewStore.displayText }, set: { viewStore.send(.wordChanged(input: $0.lowercased().trimmedInclNewlin())) } diff --git a/Sources/Features/ImportMnemonic/ImportWord/ImportMnemonicWord.swift b/Sources/Features/ImportMnemonic/ImportWord/ImportMnemonicWord.swift index e64bf52926..ba01482368 100644 --- a/Sources/Features/ImportMnemonic/ImportWord/ImportMnemonicWord.swift +++ b/Sources/Features/ImportMnemonic/ImportWord/ImportMnemonicWord.swift @@ -69,6 +69,7 @@ public struct ImportMnemonicWord: Sendable, FeatureReducer { public typealias ID = Int public let id: ID public var value: WordValue + public let placeholder: String public let isReadonlyMode: Bool public var autocompletionCandidates: AutocompletionCandidates? = nil @@ -77,10 +78,12 @@ public struct ImportMnemonicWord: Sendable, FeatureReducer { public init( id: ID, value: WordValue = .incomplete(text: "", hasFailedValidation: false), + placeholder: String, isReadonlyMode: Bool ) { self.id = id self.value = value + self.placeholder = placeholder self.isReadonlyMode = isReadonlyMode } diff --git a/Sources/Prelude/UInt11.swift b/Sources/Prelude/UInt11.swift index 64491c2c6d..05d1f845e5 100644 --- a/Sources/Prelude/UInt11.swift +++ b/Sources/Prelude/UInt11.swift @@ -6,7 +6,7 @@ public struct UInt11: Sendable, Hashable, ExpressibleByIntegerLiteral, Comparabl public let valueBoundBy16Bits: UInt16 - internal init?(valueBoundBy16Bits: UInt16) { + public init?(valueBoundBy16Bits: UInt16) { if valueBoundBy16Bits > UInt11.max16 { return nil } diff --git a/Tests/Features/AppFeatureTests/AppFeatureTests.swift b/Tests/Features/AppFeatureTests/AppFeatureTests.swift index 02fd49e9d1..0ca6c3f074 100644 --- a/Tests/Features/AppFeatureTests/AppFeatureTests.swift +++ b/Tests/Features/AppFeatureTests/AppFeatureTests.swift @@ -30,7 +30,7 @@ final class AppFeatureTests: TestCase { } func test_splash__GIVEN__an_existing_profile__WHEN__existing_profile_loaded__THEN__we_navigate_to_main() async throws { - // GIVEN: an existing profile (ephemeralPrivateProfile) + // GIVEN: an existinœg profile let accountRecoveryNeeded = true let clock = TestClock() let store = TestStore( @@ -45,7 +45,7 @@ final class AppFeatureTests: TestCase { } } - // then + // THEN: navigate to main await store.send(.child(.splash(.delegate(.loadProfileOutcome(.existingProfile))))) await store.receive(.internal(.toMain(isAccountRecoveryNeeded: accountRecoveryNeeded))) { $0.root = .main(.init(home: .init(accountRecoveryIsNeeded: accountRecoveryNeeded)))