- Add Suite0_OnboardingTests with fresh install and login test flows - Add accessibility identifiers to onboarding views for UI testing - Remove deprecated DataCache in favor of unified DataManager - Update API layer to support public upgrade-triggers endpoint - Improve onboarding first task view with better date handling - Update various views with accessibility identifiers for testing - Fix subscription feature comparison view layout - Update document detail view improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
352 lines
14 KiB
Swift
352 lines
14 KiB
Swift
import SwiftUI
|
|
import AuthenticationServices
|
|
import ComposeApp
|
|
|
|
/// Screen 4: Create Account / Sign In with Apple - Content only (no navigation bar)
|
|
struct OnboardingCreateAccountContent: View {
|
|
var onAccountCreated: (Bool) -> Void // Bool indicates if user is already verified
|
|
|
|
@StateObject private var viewModel = RegisterViewModel()
|
|
@StateObject private var appleSignInViewModel = AppleSignInViewModel()
|
|
@State private var showingLoginSheet = false
|
|
@State private var isExpanded = false
|
|
@FocusState private var focusedField: Field?
|
|
|
|
enum Field {
|
|
case username, email, password, confirmPassword
|
|
}
|
|
|
|
private var isFormValid: Bool {
|
|
!viewModel.username.isEmpty &&
|
|
!viewModel.email.isEmpty &&
|
|
!viewModel.password.isEmpty &&
|
|
viewModel.password == viewModel.confirmPassword
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: AppSpacing.xl) {
|
|
// Header
|
|
VStack(spacing: AppSpacing.sm) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.appPrimary.opacity(0.1))
|
|
.frame(width: 80, height: 80)
|
|
|
|
Image(systemName: "person.badge.plus")
|
|
.font(.system(size: 36))
|
|
.foregroundStyle(Color.appPrimary.gradient)
|
|
}
|
|
|
|
Text("Save your home to your account")
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(Color.appTextPrimary)
|
|
.multilineTextAlignment(.center)
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountTitle)
|
|
|
|
Text("Your data will be synced across devices")
|
|
.font(.subheadline)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.padding(.top, AppSpacing.lg)
|
|
|
|
// Sign in with Apple (Primary)
|
|
VStack(spacing: AppSpacing.md) {
|
|
SignInWithAppleButton(
|
|
onRequest: { request in
|
|
request.requestedScopes = [.fullName, .email]
|
|
},
|
|
onCompletion: { _ in }
|
|
)
|
|
.frame(height: 56)
|
|
.cornerRadius(AppRadius.md)
|
|
.signInWithAppleButtonStyle(.black)
|
|
.disabled(appleSignInViewModel.isLoading)
|
|
.opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0)
|
|
.overlay {
|
|
Color.clear
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
appleSignInViewModel.signInWithApple()
|
|
}
|
|
}
|
|
|
|
if appleSignInViewModel.isLoading {
|
|
HStack {
|
|
ProgressView()
|
|
Text("Signing in with Apple...")
|
|
.font(.subheadline)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
}
|
|
|
|
if let error = appleSignInViewModel.errorMessage {
|
|
errorMessage(error)
|
|
}
|
|
}
|
|
|
|
// Divider
|
|
HStack {
|
|
Rectangle()
|
|
.fill(Color.appTextSecondary.opacity(0.3))
|
|
.frame(height: 1)
|
|
Text("or")
|
|
.font(.subheadline)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.padding(.horizontal, AppSpacing.sm)
|
|
Rectangle()
|
|
.fill(Color.appTextSecondary.opacity(0.3))
|
|
.frame(height: 1)
|
|
}
|
|
|
|
// Create Account Form
|
|
VStack(spacing: AppSpacing.md) {
|
|
if !isExpanded {
|
|
// Collapsed state
|
|
Button(action: {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
isExpanded = true
|
|
}
|
|
}) {
|
|
HStack(spacing: AppSpacing.sm) {
|
|
Image(systemName: "envelope.fill")
|
|
.font(.title3)
|
|
Text("Create Account with Email")
|
|
.font(.headline)
|
|
.fontWeight(.medium)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 56)
|
|
.foregroundColor(Color.appPrimary)
|
|
.background(Color.appPrimary.opacity(0.1))
|
|
.cornerRadius(AppRadius.md)
|
|
}
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.emailSignUpExpandButton)
|
|
} else {
|
|
// Expanded form
|
|
VStack(spacing: AppSpacing.md) {
|
|
// Username
|
|
formField(
|
|
icon: "person.fill",
|
|
placeholder: "Username",
|
|
text: $viewModel.username,
|
|
field: .username,
|
|
keyboardType: .default,
|
|
contentType: .username
|
|
)
|
|
|
|
// Email
|
|
formField(
|
|
icon: "envelope.fill",
|
|
placeholder: "Email",
|
|
text: $viewModel.email,
|
|
field: .email,
|
|
keyboardType: .emailAddress,
|
|
contentType: .emailAddress
|
|
)
|
|
|
|
// Password
|
|
secureFormField(
|
|
icon: "lock.fill",
|
|
placeholder: "Password",
|
|
text: $viewModel.password,
|
|
field: .password
|
|
)
|
|
|
|
// Confirm Password
|
|
secureFormField(
|
|
icon: "lock.fill",
|
|
placeholder: "Confirm Password",
|
|
text: $viewModel.confirmPassword,
|
|
field: .confirmPassword
|
|
)
|
|
|
|
if let error = viewModel.errorMessage {
|
|
errorMessage(error)
|
|
}
|
|
|
|
// Register button
|
|
Button(action: {
|
|
viewModel.register()
|
|
}) {
|
|
HStack {
|
|
if viewModel.isLoading {
|
|
ProgressView()
|
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
}
|
|
Text(viewModel.isLoading ? "Creating Account..." : "Create Account")
|
|
.font(.headline)
|
|
.fontWeight(.semibold)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 56)
|
|
.foregroundColor(Color.appTextOnPrimary)
|
|
.background(
|
|
isFormValid && !viewModel.isLoading
|
|
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
: AnyShapeStyle(Color.appTextSecondary)
|
|
)
|
|
.cornerRadius(AppRadius.md)
|
|
.shadow(color: isFormValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
|
|
}
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountButton)
|
|
.disabled(!isFormValid || viewModel.isLoading)
|
|
}
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
}
|
|
|
|
// Already have an account
|
|
HStack(spacing: AppSpacing.xs) {
|
|
Text("Already have an account?")
|
|
.font(.body)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
|
|
Button("Log in") {
|
|
showingLoginSheet = true
|
|
}
|
|
.font(.body)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
.padding(.top, AppSpacing.md)
|
|
}
|
|
.padding(.horizontal, AppSpacing.xl)
|
|
.padding(.bottom, AppSpacing.xxxl)
|
|
}
|
|
.background(Color.appBackgroundPrimary)
|
|
.sheet(isPresented: $showingLoginSheet) {
|
|
LoginView(onLoginSuccess: {
|
|
showingLoginSheet = false
|
|
onAccountCreated(true)
|
|
})
|
|
}
|
|
.onChange(of: viewModel.isRegistered) { _, isRegistered in
|
|
if isRegistered {
|
|
// Registration successful - user is authenticated but not verified
|
|
onAccountCreated(false)
|
|
}
|
|
}
|
|
.onAppear {
|
|
// Set up Apple Sign In callback
|
|
appleSignInViewModel.onSignInSuccess = { isVerified in
|
|
AuthenticationManager.shared.login(verified: isVerified)
|
|
// Residence creation is handled by the coordinator
|
|
onAccountCreated(isVerified)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Form Fields
|
|
|
|
private func formField(
|
|
icon: String,
|
|
placeholder: String,
|
|
text: Binding<String>,
|
|
field: Field,
|
|
keyboardType: UIKeyboardType,
|
|
contentType: UITextContentType
|
|
) -> some View {
|
|
HStack(spacing: AppSpacing.sm) {
|
|
Image(systemName: icon)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.frame(width: 20)
|
|
|
|
TextField(placeholder, text: text)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.keyboardType(keyboardType)
|
|
.textContentType(contentType)
|
|
.focused($focusedField, equals: field)
|
|
}
|
|
.padding(AppSpacing.md)
|
|
.background(Color.appBackgroundSecondary)
|
|
.cornerRadius(AppRadius.md)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: AppRadius.md)
|
|
.stroke(focusedField == field ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
|
|
)
|
|
}
|
|
|
|
private func secureFormField(
|
|
icon: String,
|
|
placeholder: String,
|
|
text: Binding<String>,
|
|
field: Field
|
|
) -> some View {
|
|
HStack(spacing: AppSpacing.sm) {
|
|
Image(systemName: icon)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.frame(width: 20)
|
|
|
|
SecureField(placeholder, text: text)
|
|
.textContentType(.password)
|
|
.focused($focusedField, equals: field)
|
|
}
|
|
.padding(AppSpacing.md)
|
|
.background(Color.appBackgroundSecondary)
|
|
.cornerRadius(AppRadius.md)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: AppRadius.md)
|
|
.stroke(focusedField == field ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
|
|
)
|
|
}
|
|
|
|
private func errorMessage(_ message: String) -> some View {
|
|
HStack(spacing: AppSpacing.sm) {
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.foregroundColor(Color.appError)
|
|
Text(message)
|
|
.font(.callout)
|
|
.foregroundColor(Color.appError)
|
|
Spacer()
|
|
}
|
|
.padding(AppSpacing.md)
|
|
.background(Color.appError.opacity(0.1))
|
|
.cornerRadius(AppRadius.md)
|
|
}
|
|
}
|
|
|
|
// MARK: - Legacy wrapper with navigation bar (for backwards compatibility)
|
|
|
|
struct OnboardingCreateAccountView: View {
|
|
var onAccountCreated: (Bool) -> Void
|
|
var onBack: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Navigation bar
|
|
HStack {
|
|
Button(action: onBack) {
|
|
Image(systemName: "chevron.left")
|
|
.font(.title2)
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
OnboardingProgressIndicator(currentStep: 3, totalSteps: 5)
|
|
|
|
Spacer()
|
|
|
|
// Invisible spacer for alignment
|
|
Image(systemName: "chevron.left")
|
|
.font(.title2)
|
|
.opacity(0)
|
|
}
|
|
.padding(.horizontal, AppSpacing.lg)
|
|
.padding(.vertical, AppSpacing.md)
|
|
|
|
OnboardingCreateAccountContent(onAccountCreated: onAccountCreated)
|
|
}
|
|
.background(Color.appBackgroundPrimary)
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
OnboardingCreateAccountContent(onAccountCreated: { _ in })
|
|
}
|