Files
honeyDueKMP/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift
Trey t e7c09f687a Add keyboard dismiss toolbar for iOS numeric and multi-line fields
Creates a reusable KeyboardDismissToolbar view modifier that adds a
"Done" button to dismiss keyboards that don't have a return key.
Applied to all numeric keyboards (numberPad, decimalPad, phonePad)
and multi-line text inputs (TextEditor, TextField with axis: .vertical).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 21:20:33 -06:00

197 lines
7.7 KiB
Swift

import SwiftUI
import ComposeApp
/// Screen 5: Email verification during onboarding - Content only (no navigation bar)
struct OnboardingVerifyEmailContent: View {
var onVerified: () -> Void
@StateObject private var viewModel = VerifyEmailViewModel()
@FocusState private var isCodeFieldFocused: Bool
@State private var hasCalledOnVerified = false
var body: some View {
VStack(spacing: 0) {
Spacer()
// Content
VStack(spacing: AppSpacing.xl) {
// Icon
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 100, height: 100)
Image(systemName: "envelope.badge.fill")
.font(.system(size: 44))
.foregroundStyle(Color.appPrimary.gradient)
}
// Title
VStack(spacing: AppSpacing.sm) {
Text("Verify your email")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle)
Text("We sent a 6-digit code to your email address. Enter it below to verify your account.")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
// Code input
VStack(alignment: .leading, spacing: AppSpacing.xs) {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "key.fill")
.foregroundColor(Color.appTextSecondary)
.frame(width: 20)
TextField("Enter 6-digit code", text: $viewModel.code)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.focused($isCodeFieldFocused)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField)
.keyboardDismissToolbar()
.onChange(of: viewModel.code) { _, newValue in
// Limit to 6 digits
if newValue.count > 6 {
viewModel.code = String(newValue.prefix(6))
}
// Auto-verify when 6 digits entered
if newValue.count == 6 {
viewModel.verifyEmail()
}
}
}
.padding(AppSpacing.md)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.md)
.stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
)
}
.padding(.horizontal, AppSpacing.xl)
// Error message
if let error = viewModel.errorMessage {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(error)
.font(.callout)
.foregroundColor(Color.appError)
Spacer()
}
.padding(AppSpacing.md)
.background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.md)
.padding(.horizontal, AppSpacing.xl)
}
// Loading indicator
if viewModel.isLoading {
HStack {
ProgressView()
Text("Verifying...")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
}
// Resend code hint
Text("Didn't receive a code? Check your spam folder or re-register")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
Spacer()
// Verify button
Button(action: {
viewModel.verifyEmail()
}) {
Text("Verify")
.font(.headline)
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
viewModel.code.count == 6 && !viewModel.isLoading
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary)
)
.cornerRadius(AppRadius.md)
.shadow(color: viewModel.code.count == 6 ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyButton)
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
.padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl)
}
.background(Color.appBackgroundPrimary)
.onAppear {
print("🏠 ONBOARDING: OnboardingVerifyEmailContent appeared")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isCodeFieldFocused = true
}
}
.onReceive(viewModel.$isVerified) { isVerified in
print("🏠 ONBOARDING: onReceive isVerified = \(isVerified), hasCalledOnVerified = \(hasCalledOnVerified)")
if isVerified && !hasCalledOnVerified {
hasCalledOnVerified = true
print("🏠 ONBOARDING: Calling onVerified callback FIRST (before markVerified)")
// CRITICAL: Call onVerified FIRST so coordinator can create residence
// BEFORE markVerified changes auth state and disposes this view
onVerified()
}
}
}
}
// MARK: - Legacy wrapper with navigation bar (for backwards compatibility)
struct OnboardingVerifyEmailView: View {
var onVerified: () -> Void
var onLogout: () -> Void
var body: some View {
VStack(spacing: 0) {
// Navigation bar
HStack {
// Logout option
Button(action: onLogout) {
Text("Back")
.font(.subheadline)
.foregroundColor(Color.appPrimary)
}
Spacer()
OnboardingProgressIndicator(currentStep: 4, totalSteps: 5)
Spacer()
// Invisible spacer for alignment
Text("Back")
.font(.subheadline)
.opacity(0)
}
.padding(.horizontal, AppSpacing.lg)
.padding(.vertical, AppSpacing.md)
OnboardingVerifyEmailContent(onVerified: onVerified)
}
.background(Color.appBackgroundPrimary)
}
}
// MARK: - Preview
#Preview {
OnboardingVerifyEmailContent(onVerified: {})
}