Add onboarding, auth flow, design system and project structure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
289
Rosetta/Features/Auth/ConfirmSeedPhraseView.swift
Normal file
289
Rosetta/Features/Auth/ConfirmSeedPhraseView.swift
Normal file
@@ -0,0 +1,289 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConfirmSeedPhraseView: View {
|
||||
let seedPhrase: [String]
|
||||
let onConfirmed: () -> Void
|
||||
let onBack: () -> Void
|
||||
|
||||
@State private var confirmationInputs: [String] = Array(repeating: "", count: 4)
|
||||
@State private var showError = false
|
||||
@State private var showPasteSuccess = false
|
||||
@FocusState private var focusedInputIndex: Int?
|
||||
|
||||
private let confirmPositions = [1, 4, 8, 11]
|
||||
|
||||
private var allCorrect: Bool {
|
||||
for (inputIndex, seedIndex) in confirmPositions.enumerated() {
|
||||
let input = confirmationInputs[inputIndex].lowercased().trimmingCharacters(in: .whitespaces)
|
||||
if input != seedPhrase[seedIndex].lowercased() { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
AuthNavigationBar(onBack: onBack)
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 24) {
|
||||
headerSection
|
||||
pasteButton
|
||||
pasteSuccessMessage
|
||||
wordGrid
|
||||
errorMessage
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 100)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onTapGesture { focusedInputIndex = nil }
|
||||
|
||||
confirmButton
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private extension ConfirmSeedPhraseView {
|
||||
var headerSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("Confirm Backup")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Enter words #2, #5, #9, #12 to confirm\nyou've backed up your phrase.")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.secondaryText)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(3)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Word Grid
|
||||
|
||||
private extension ConfirmSeedPhraseView {
|
||||
var wordGrid: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(0..<6, id: \.self) { index in
|
||||
wordRow(seedIndex: index, displayNumber: index + 1)
|
||||
.staggeredAppearance(index: index, baseDelay: 0.2, stagger: 0.04)
|
||||
}
|
||||
}
|
||||
VStack(spacing: 10) {
|
||||
ForEach(6..<12, id: \.self) { index in
|
||||
wordRow(seedIndex: index, displayNumber: index + 1)
|
||||
.staggeredAppearance(index: index, baseDelay: 0.2, stagger: 0.04)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func wordRow(seedIndex: Int, displayNumber: Int) -> some View {
|
||||
if let inputIndex = confirmPositions.firstIndex(of: seedIndex) {
|
||||
editableWordRow(number: displayNumber, inputIndex: inputIndex, expected: seedPhrase[seedIndex])
|
||||
} else {
|
||||
readOnlyWordRow(number: displayNumber, word: seedPhrase[seedIndex])
|
||||
}
|
||||
}
|
||||
|
||||
func readOnlyWordRow(number: Int, word: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("\(number).")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.numberGray)
|
||||
.frame(width: 28, alignment: .trailing)
|
||||
|
||||
Text(word)
|
||||
.font(.system(size: 17, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(RosettaColors.tertiaryText)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(RosettaColors.cardFill)
|
||||
}
|
||||
.accessibilityLabel("Word \(number): \(word)")
|
||||
}
|
||||
|
||||
func editableWordRow(number: Int, inputIndex: Int, expected: String) -> some View {
|
||||
let text = confirmationInputs[inputIndex].lowercased().trimmingCharacters(in: .whitespaces)
|
||||
let isCorrect = !text.isEmpty && text == expected.lowercased()
|
||||
let isWrong = !text.isEmpty && text != expected.lowercased()
|
||||
|
||||
return HStack(spacing: 8) {
|
||||
Text("\(number).")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.numberGray)
|
||||
.frame(width: 28, alignment: .trailing)
|
||||
|
||||
TextField("enter word", text: $confirmationInputs[inputIndex])
|
||||
.font(.system(size: 17, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(.white)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($focusedInputIndex, equals: inputIndex)
|
||||
.onChange(of: confirmationInputs[inputIndex]) { _, newValue in
|
||||
confirmationInputs[inputIndex] = newValue.lowercased()
|
||||
if showError { withAnimation { showError = false } }
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
validationIcon(isCorrect: isCorrect, isWrong: isWrong)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background {
|
||||
inputBorder(isCorrect: isCorrect, isWrong: isWrong, isFocused: focusedInputIndex == inputIndex)
|
||||
}
|
||||
.accessibilityLabel("Enter word \(number)")
|
||||
.accessibilityValue(confirmationInputs[inputIndex].isEmpty ? "Empty" : confirmationInputs[inputIndex])
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func validationIcon(isCorrect: Bool, isWrong: Bool) -> some View {
|
||||
if isCorrect {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(RosettaColors.success)
|
||||
.font(.system(size: 18))
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
} else if isWrong {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
.font(.system(size: 18))
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
|
||||
func inputBorder(isCorrect: Bool, isWrong: Bool, isFocused: Bool) -> some View {
|
||||
let borderColor: Color =
|
||||
isCorrect ? RosettaColors.success :
|
||||
isWrong ? RosettaColors.error :
|
||||
isFocused ? RosettaColors.primaryBlue :
|
||||
RosettaColors.subtleBorder
|
||||
|
||||
return RoundedRectangle(cornerRadius: 12)
|
||||
.fill(RosettaColors.cardFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(borderColor, lineWidth: isCorrect || isWrong || isFocused ? 2 : 1)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: borderColor)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paste
|
||||
|
||||
private extension ConfirmSeedPhraseView {
|
||||
var pasteButton: some View {
|
||||
Button(action: pasteFromClipboard) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
.font(.system(size: 14))
|
||||
Text("Paste from Clipboard")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(RosettaColors.primaryBlue)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(RosettaColors.primaryBlue, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.accessibilityHint("Pastes your saved seed phrase")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var pasteSuccessMessage: some View {
|
||||
if showPasteSuccess {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(RosettaColors.success)
|
||||
Text("Words pasted successfully!")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.success)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(RosettaColors.success.opacity(0.15))
|
||||
}
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||
}
|
||||
}
|
||||
|
||||
func pasteFromClipboard() {
|
||||
guard let text = UIPasteboard.general.string else { return }
|
||||
let words = text
|
||||
.components(separatedBy: CharacterSet.whitespacesAndNewlines.union(.init(charactersIn: ",")))
|
||||
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
guard words.count >= 12 else { return }
|
||||
|
||||
for (inputIndex, seedIndex) in confirmPositions.enumerated() {
|
||||
confirmationInputs[inputIndex] = words[seedIndex]
|
||||
}
|
||||
|
||||
withAnimation(.spring(response: 0.3)) { showPasteSuccess = true }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
withAnimation { showPasteSuccess = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error & Confirm
|
||||
|
||||
private extension ConfirmSeedPhraseView {
|
||||
@ViewBuilder
|
||||
var errorMessage: some View {
|
||||
if showError {
|
||||
Text("Some words don't match. Please check and try again.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
.multilineTextAlignment(.center)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||
}
|
||||
}
|
||||
|
||||
var confirmButton: some View {
|
||||
Button {
|
||||
if allCorrect {
|
||||
onConfirmed()
|
||||
} else {
|
||||
withAnimation(.spring(response: 0.3)) { showError = true }
|
||||
}
|
||||
} label: {
|
||||
Text("Confirm")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
}
|
||||
.buttonStyle(RosettaPrimaryButtonStyle(isEnabled: allCorrect))
|
||||
.accessibilityHint(allCorrect ? "Confirms your seed phrase backup" : "Fill in all words correctly first")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ConfirmSeedPhraseView(
|
||||
seedPhrase: SeedPhraseGenerator.generate(),
|
||||
onConfirmed: {},
|
||||
onBack: {}
|
||||
)
|
||||
.preferredColorScheme(.dark)
|
||||
.background(RosettaColors.authBackground)
|
||||
}
|
||||
Reference in New Issue
Block a user