Files
mobile-ios/Rosetta/Features/Auth/SetPasswordView.swift

290 lines
9.6 KiB
Swift

import SwiftUI
struct SetPasswordView: View {
let seedPhrase: [String]
let isImportMode: Bool
let onAccountCreated: () -> Void
let onBack: () -> Void
@State private var password = ""
@State private var confirmPassword = ""
@State private var showPassword = false
@State private var showConfirmPassword = false
@State private var isCreating = false
@State private var errorMessage: String?
@FocusState private var focusedField: Field?
fileprivate enum Field {
case password, confirm
}
private var passwordsMatch: Bool {
!password.isEmpty && password == confirmPassword
}
private var canCreate: Bool {
passwordsMatch && !isCreating
}
var body: some View {
VStack(spacing: 0) {
AuthNavigationBar(onBack: onBack)
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
lockIcon
headerSection
VStack(spacing: 8) {
passwordField
PasswordStrengthIndicator(password: password)
}
VStack(spacing: 8) {
confirmField
PasswordMatchIndicator(password: password, confirmPassword: confirmPassword)
}
WeakPasswordWarning(password: password)
if let message = errorMessage {
Text(message)
.font(.system(size: 13))
.foregroundStyle(RosettaColors.error)
.multilineTextAlignment(.center)
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
}
.padding(.horizontal, 24)
.padding(.top, 8)
.padding(.bottom, 32)
}
.scrollDismissesKeyboard(.interactively)
.onTapGesture(count: 1) { focusedField = nil }
.simultaneousGesture(TapGesture().onEnded {})
Spacer()
VStack(spacing: 16) {
infoCard
createButton
}
.padding(.horizontal, 24)
.padding(.bottom, 16)
}
.ignoresSafeArea(.keyboard)
}
}
// MARK: - Lock Icon
private extension SetPasswordView {
var lockIcon: some View {
GlassCard(cornerRadius: 20, fillOpacity: 0.1) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 32))
.foregroundStyle(RosettaColors.primaryBlue)
.frame(width: 72, height: 72)
}
.accessibilityHidden(true)
}
}
// MARK: - Header
private extension SetPasswordView {
var headerSection: some View {
VStack(spacing: 8) {
Text(isImportMode ? "Recover Account" : "Protect Your Account")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.white)
Text(isImportMode
? "Set a password to protect your recovered account.\nYou'll need it to unlock Rosetta."
: "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.secondaryText)
.multilineTextAlignment(.center)
.lineSpacing(2)
}
.accessibilityElement(children: .combine)
}
}
// MARK: - Password Fields
private extension SetPasswordView {
var passwordField: some View {
secureInputField(
placeholder: "Password",
text: $password,
isSecure: !showPassword,
toggleAction: { showPassword.toggle() },
isRevealed: showPassword,
field: .password
)
.accessibilityLabel("Password input")
}
var confirmField: some View {
secureInputField(
placeholder: "Confirm Password",
text: $confirmPassword,
isSecure: !showConfirmPassword,
toggleAction: { showConfirmPassword.toggle() },
isRevealed: showConfirmPassword,
field: .confirm
)
.accessibilityLabel("Confirm password input")
}
func secureInputField(
placeholder: String,
text: Binding<String>,
isSecure: Bool,
toggleAction: @escaping () -> Void,
isRevealed: Bool,
field: Field
) -> some View {
HStack(spacing: 12) {
ZStack(alignment: .leading) {
// Placeholder (shown when text is empty)
if text.wrappedValue.isEmpty {
Text(placeholder)
.font(.system(size: 16))
.foregroundStyle(RosettaColors.tertiaryText)
}
// Actual input always the same type trick: use overlay to keep focus
if isSecure {
SecureField("", text: text)
.font(.system(size: 16))
.foregroundStyle(.white)
.focused($focusedField, equals: field)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} else {
TextField("", text: text)
.font(.system(size: 16))
.foregroundStyle(.white)
.focused($focusedField, equals: field)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
}
.frame(height: 22)
Image(systemName: isRevealed ? "eye.slash" : "eye")
.font(.system(size: 16))
.foregroundStyle(RosettaColors.secondaryText)
.frame(width: 30, height: 30)
.contentShape(Rectangle())
.onTapGesture {
// Save and restore focus to prevent drop
let currentFocus = focusedField
toggleAction()
DispatchQueue.main.async {
focusedField = currentFocus
}
}
.accessibilityLabel(isRevealed ? "Hide password" : "Show password")
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background {
let isFocused = focusedField == field
RoundedRectangle(cornerRadius: 12)
.fill(RosettaColors.cardFill)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(
isFocused ? RosettaColors.primaryBlue : RosettaColors.subtleBorder,
lineWidth: isFocused ? 2 : 1
)
}
.animation(.easeInOut(duration: 0.2), value: isFocused)
}
}
}
// MARK: - Info Card
private extension SetPasswordView {
var infoCard: some View {
GlassCard(cornerRadius: 12, fillOpacity: 0.08) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "info.circle.fill")
.foregroundStyle(RosettaColors.primaryBlue)
.font(.system(size: 16))
Text("Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.secondaryText)
.lineSpacing(2)
}
.padding(16)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Your password is only used for local encryption and is never sent anywhere.")
}
}
// MARK: - Create Button
private extension SetPasswordView {
@ViewBuilder
var createButton: some View {
Button(action: createAccount) {
Group {
if isCreating {
ProgressView()
.tint(.white)
} else {
Text(isImportMode ? "Recover Account" : "Create Account")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
}
}
.frame(maxWidth: .infinity)
.frame(height: 56)
}
.buttonStyle(RosettaPrimaryButtonStyle(isEnabled: canCreate))
.shine(tintColor: canCreate
? Color(red: 0.35, green: 0.82, blue: 0.98).opacity(0.50)
: .clear)
.accessibilityHint(canCreate ? "Creates your encrypted account" : "Enter matching passwords first")
}
func createAccount() {
guard canCreate else { return }
isCreating = true
Task {
do {
_ = try await AccountManager.shared.createAccount(
seedPhrase: seedPhrase,
password: password
)
// Start session (WebSocket + handshake) immediately after account creation
try await SessionManager.shared.startSession(password: password)
isCreating = false
onAccountCreated()
} catch {
isCreating = false
errorMessage = error.localizedDescription
}
}
}
}
#Preview {
SetPasswordView(
seedPhrase: ["abandon", "ability", "able", "about", "above", "absent",
"absorb", "abstract", "absurd", "abuse", "access", "accident"],
isImportMode: false,
onAccountCreated: {},
onBack: {}
)
.preferredColorScheme(.dark)
.background(RosettaColors.authBackground)
}