290 lines
9.6 KiB
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)
|
|
}
|