Add iOS onboarding flow with residence creation and task templates
- Add complete onboarding flow with 7 screens: Welcome, Name Residence, Value Props, Create Account, Verify Email, First Task, Subscription - Auto-create residence after email verification for "Start Fresh" users - Add predefined task templates (HVAC, Smoke Detectors, Lawn Care, Leaks) that create real tasks with today as due date - Add returning user login button on welcome screen - Update RootView to prioritize onboarding flow for first-time users - Use app icon asset instead of house.fill SF Symbol - Smooth slide transitions with fade-out for back navigation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
344
iosApp/iosApp/Onboarding/OnboardingCoordinator.swift
Normal file
344
iosApp/iosApp/Onboarding/OnboardingCoordinator.swift
Normal file
@@ -0,0 +1,344 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
/// Coordinates the onboarding flow, presenting the appropriate view based on current step
|
||||
struct OnboardingCoordinator: View {
|
||||
@StateObject private var onboardingState = OnboardingState.shared
|
||||
@StateObject private var residenceViewModel = ResidenceViewModel()
|
||||
@State private var showingRegister = false
|
||||
@State private var showingLogin = false
|
||||
@State private var isNavigatingBack = false
|
||||
@State private var isCreatingResidence = false
|
||||
|
||||
var onComplete: () -> Void
|
||||
|
||||
/// Transition that respects navigation direction
|
||||
private var navigationTransition: AnyTransition {
|
||||
if isNavigatingBack {
|
||||
// Going back: new view slides in from left, old view fades out in place
|
||||
return .asymmetric(
|
||||
insertion: .move(edge: .leading),
|
||||
removal: .opacity
|
||||
)
|
||||
} else {
|
||||
// Going forward: new view slides in from right, old view fades out in place
|
||||
return .asymmetric(
|
||||
insertion: .move(edge: .trailing),
|
||||
removal: .opacity
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func goBack(to step: OnboardingStep) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
isNavigatingBack = true
|
||||
onboardingState.currentStep = step
|
||||
}
|
||||
// Reset after animation completes
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
isNavigatingBack = false
|
||||
}
|
||||
}
|
||||
|
||||
private func goForward() {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
isNavigatingBack = false
|
||||
onboardingState.nextStep()
|
||||
}
|
||||
}
|
||||
|
||||
private func goForward(to step: OnboardingStep) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
isNavigatingBack = false
|
||||
onboardingState.currentStep = step
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a residence with the pending name from onboarding, then calls completion
|
||||
private func createResidenceIfNeeded(thenNavigateTo step: OnboardingStep) {
|
||||
print("🏠 ONBOARDING: createResidenceIfNeeded called")
|
||||
print("🏠 ONBOARDING: userIntent = \(onboardingState.userIntent)")
|
||||
print("🏠 ONBOARDING: pendingResidenceName = '\(onboardingState.pendingResidenceName)'")
|
||||
|
||||
// Only create residence if user is starting fresh (not joining existing)
|
||||
guard onboardingState.userIntent == .startFresh,
|
||||
!onboardingState.pendingResidenceName.isEmpty else {
|
||||
print("🏠 ONBOARDING: Skipping residence creation - conditions not met")
|
||||
goForward(to: step)
|
||||
return
|
||||
}
|
||||
|
||||
print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName)")
|
||||
|
||||
isCreatingResidence = true
|
||||
|
||||
let request = ResidenceCreateRequest(
|
||||
name: onboardingState.pendingResidenceName,
|
||||
propertyTypeId: nil,
|
||||
streetAddress: nil,
|
||||
apartmentUnit: nil,
|
||||
city: nil,
|
||||
stateProvince: nil,
|
||||
postalCode: nil,
|
||||
country: nil,
|
||||
bedrooms: nil,
|
||||
bathrooms: nil,
|
||||
squareFootage: nil,
|
||||
lotSize: nil,
|
||||
yearBuilt: nil,
|
||||
description: nil,
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: KotlinBoolean(bool: true)
|
||||
)
|
||||
|
||||
residenceViewModel.createResidence(request: request) { success in
|
||||
print("🏠 ONBOARDING: Residence creation result: \(success ? "SUCCESS" : "FAILED")")
|
||||
self.isCreatingResidence = false
|
||||
// Navigate regardless of success - user can create residence later if needed
|
||||
self.goForward(to: step)
|
||||
}
|
||||
}
|
||||
|
||||
/// Current step index for progress indicator (0-based)
|
||||
private var currentProgressStep: Int {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome: return 0
|
||||
case .nameResidence: return 1
|
||||
case .valueProps: return 2
|
||||
case .createAccount: return 3
|
||||
case .verifyEmail: return 4
|
||||
case .joinResidence: return 4
|
||||
case .firstTask: return 4
|
||||
case .subscriptionUpsell: return 4
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to show the back button
|
||||
private var showBackButton: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome, .joinResidence, .firstTask, .subscriptionUpsell:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to show the skip button
|
||||
private var showSkipButton: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .valueProps, .joinResidence, .firstTask, .subscriptionUpsell:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to show the progress indicator
|
||||
private var showProgressIndicator: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome, .joinResidence, .firstTask, .subscriptionUpsell:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func handleBack() {
|
||||
switch onboardingState.currentStep {
|
||||
case .nameResidence:
|
||||
goBack(to: .welcome)
|
||||
case .valueProps:
|
||||
goBack(to: .nameResidence)
|
||||
case .createAccount:
|
||||
if onboardingState.userIntent == .joinExisting {
|
||||
goBack(to: .welcome)
|
||||
} else {
|
||||
goBack(to: .valueProps)
|
||||
}
|
||||
case .verifyEmail:
|
||||
AuthenticationManager.shared.logout()
|
||||
goBack(to: .createAccount)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSkip() {
|
||||
switch onboardingState.currentStep {
|
||||
case .valueProps:
|
||||
goForward()
|
||||
case .joinResidence, .firstTask:
|
||||
goForward()
|
||||
case .subscriptionUpsell:
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Shared navigation bar - stays static
|
||||
HStack {
|
||||
// Back button
|
||||
Button(action: handleBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.title2)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
.opacity(showBackButton ? 1 : 0)
|
||||
.disabled(!showBackButton)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Progress indicator
|
||||
if showProgressIndicator {
|
||||
OnboardingProgressIndicator(currentStep: currentProgressStep, totalSteps: 5)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Skip button
|
||||
Button(action: handleSkip) {
|
||||
Text("Skip")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.opacity(showSkipButton ? 1 : 0)
|
||||
.disabled(!showSkipButton)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.lg)
|
||||
.padding(.vertical, AppSpacing.md)
|
||||
|
||||
// Content area - this is what transitions
|
||||
ZStack {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome:
|
||||
OnboardingWelcomeContent(
|
||||
onStartFresh: {
|
||||
onboardingState.userIntent = .startFresh
|
||||
goForward()
|
||||
},
|
||||
onJoinExisting: {
|
||||
onboardingState.userIntent = .joinExisting
|
||||
goForward()
|
||||
},
|
||||
onLogin: {
|
||||
// Returning user logged in - mark onboarding complete and go to main view
|
||||
onboardingState.completeOnboarding()
|
||||
AuthenticationManager.shared.markVerified()
|
||||
onComplete()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .nameResidence:
|
||||
OnboardingNameResidenceContent(
|
||||
residenceName: $onboardingState.pendingResidenceName,
|
||||
onContinue: {
|
||||
goForward()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .valueProps:
|
||||
OnboardingValuePropsContent(
|
||||
onContinue: {
|
||||
goForward()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .createAccount:
|
||||
OnboardingCreateAccountContent(
|
||||
onAccountCreated: { isVerified in
|
||||
if isVerified {
|
||||
// Skip email verification
|
||||
if onboardingState.userIntent == .joinExisting {
|
||||
goForward(to: .joinResidence)
|
||||
} else {
|
||||
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
||||
}
|
||||
} else {
|
||||
goForward()
|
||||
}
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .verifyEmail:
|
||||
OnboardingVerifyEmailContent(
|
||||
onVerified: {
|
||||
print("🏠 ONBOARDING: onVerified callback triggered in coordinator")
|
||||
// NOTE: Do NOT call markVerified() here - it would cause RootView
|
||||
// to switch to MainTabView before onboarding completes.
|
||||
// markVerified() is called at the end of onboarding in onComplete.
|
||||
if onboardingState.userIntent == .joinExisting {
|
||||
goForward(to: .joinResidence)
|
||||
} else {
|
||||
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
||||
}
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .joinResidence:
|
||||
OnboardingJoinResidenceContent(
|
||||
onJoined: {
|
||||
goForward()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .firstTask:
|
||||
OnboardingFirstTaskContent(
|
||||
residenceName: onboardingState.pendingResidenceName,
|
||||
onTaskAdded: {
|
||||
goForward()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .subscriptionUpsell:
|
||||
OnboardingSubscriptionContent(
|
||||
onSubscribe: {
|
||||
// Handle subscription flow
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: onboardingState.currentStep)
|
||||
}
|
||||
.background(Color.appBackgroundPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress Indicator
|
||||
|
||||
struct OnboardingProgressIndicator: View {
|
||||
let currentStep: Int
|
||||
let totalSteps: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: AppSpacing.xs) {
|
||||
ForEach(0..<totalSteps, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index <= currentStep ? Color.appPrimary : Color.appTextSecondary.opacity(0.3))
|
||||
.frame(width: 8, height: 8)
|
||||
.animation(.easeInOut(duration: 0.2), value: currentStep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
OnboardingCoordinator(onComplete: {})
|
||||
}
|
||||
348
iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift
Normal file
348
iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift
Normal file
@@ -0,0 +1,348 @@
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
.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 })
|
||||
}
|
||||
320
iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift
Normal file
320
iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift
Normal file
@@ -0,0 +1,320 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
/// Screen 6: First task prompt with suggested templates - Content only (no navigation bar)
|
||||
struct OnboardingFirstTaskContent: View {
|
||||
var residenceName: String
|
||||
var onTaskAdded: () -> Void
|
||||
|
||||
@StateObject private var viewModel = TaskViewModel()
|
||||
@State private var selectedTask: TaskTemplate?
|
||||
@State private var isCreatingTask = false
|
||||
@State private var showCustomTaskSheet = false
|
||||
|
||||
private let taskTemplates: [TaskTemplate] = [
|
||||
TaskTemplate(
|
||||
icon: "fanblades.fill",
|
||||
title: "Change HVAC Filter",
|
||||
category: "hvac",
|
||||
frequency: "monthly",
|
||||
color: Color.appPrimary
|
||||
),
|
||||
TaskTemplate(
|
||||
icon: "smoke.fill",
|
||||
title: "Check Smoke Detectors",
|
||||
category: "safety",
|
||||
frequency: "semiannually",
|
||||
color: Color.appError
|
||||
),
|
||||
TaskTemplate(
|
||||
icon: "leaf.fill",
|
||||
title: "Lawn Care",
|
||||
category: "landscaping",
|
||||
frequency: "weekly",
|
||||
color: Color(hex: "#4CAF50") ?? .green
|
||||
),
|
||||
TaskTemplate(
|
||||
icon: "drop.fill",
|
||||
title: "Check for Leaks",
|
||||
category: "plumbing",
|
||||
frequency: "monthly",
|
||||
color: Color.appSecondary
|
||||
)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
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: "checkmark.circle.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(Color.appPrimary.gradient)
|
||||
}
|
||||
|
||||
Text("Your home is ready!")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text("What's the first thing you want to track?")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.top, AppSpacing.lg)
|
||||
|
||||
// Task templates grid
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: AppSpacing.md) {
|
||||
ForEach(taskTemplates) { template in
|
||||
TaskTemplateCard(
|
||||
template: template,
|
||||
isSelected: selectedTask?.id == template.id,
|
||||
onTap: {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
if selectedTask?.id == template.id {
|
||||
selectedTask = nil
|
||||
} else {
|
||||
selectedTask = template
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.lg)
|
||||
|
||||
// Custom task option
|
||||
Button(action: {
|
||||
showCustomTaskSheet = true
|
||||
}) {
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
|
||||
Text("Add Custom Task")
|
||||
.font(.headline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.lg)
|
||||
}
|
||||
.padding(.bottom, 120) // Space for button
|
||||
}
|
||||
|
||||
// Bottom action area
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
if selectedTask != nil {
|
||||
Button(action: addSelectedTask) {
|
||||
HStack {
|
||||
if isCreatingTask {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(isCreatingTask ? "Adding Task..." : "Add Task & Continue")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5)
|
||||
}
|
||||
.disabled(isCreatingTask)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
.padding(.bottom, AppSpacing.xl)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 40)
|
||||
.offset(y: -40)
|
||||
, alignment: .top
|
||||
)
|
||||
}
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.sheet(isPresented: $showCustomTaskSheet) {
|
||||
// TODO: Show custom task form
|
||||
Text("Custom Task Form")
|
||||
}
|
||||
}
|
||||
|
||||
private func addSelectedTask() {
|
||||
guard let template = selectedTask else { return }
|
||||
|
||||
// Get the first residence from cache (just created during onboarding)
|
||||
guard let residences = DataCache.shared.residences.value as? [ResidenceResponse],
|
||||
let residence = residences.first else {
|
||||
print("🏠 ONBOARDING: No residence found in cache, skipping task creation")
|
||||
onTaskAdded()
|
||||
return
|
||||
}
|
||||
|
||||
isCreatingTask = true
|
||||
|
||||
// Look up category ID from DataCache
|
||||
let categoryId: Int32? = {
|
||||
guard let categories = DataCache.shared.taskCategories.value as? [TaskCategory] else { return nil }
|
||||
// Map template category to actual category
|
||||
let categoryName = template.category.lowercased()
|
||||
return categories.first { $0.name.lowercased() == categoryName }?.id
|
||||
}()
|
||||
|
||||
// Look up frequency ID from DataCache
|
||||
let frequencyId: Int32? = {
|
||||
guard let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency] else { return nil }
|
||||
let frequencyName = template.frequency.lowercased()
|
||||
return frequencies.first { $0.name.lowercased() == frequencyName }?.id
|
||||
}()
|
||||
|
||||
// Format today's date as YYYY-MM-DD for the API
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd"
|
||||
let todayString = dateFormatter.string(from: Date())
|
||||
|
||||
print("🏠 ONBOARDING: Creating task '\(template.title)' for residence \(residence.id)")
|
||||
print("🏠 ONBOARDING: categoryId=\(String(describing: categoryId)), frequencyId=\(String(describing: frequencyId)), dueDate=\(todayString)")
|
||||
|
||||
let request = TaskCreateRequest(
|
||||
residenceId: residence.id,
|
||||
title: template.title,
|
||||
description: nil,
|
||||
categoryId: categoryId.map { KotlinInt(int: $0) },
|
||||
priorityId: nil,
|
||||
statusId: nil,
|
||||
frequencyId: frequencyId.map { KotlinInt(int: $0) },
|
||||
assignedToId: nil,
|
||||
dueDate: todayString,
|
||||
estimatedCost: nil,
|
||||
contractorId: nil
|
||||
)
|
||||
|
||||
viewModel.createTask(request: request) { success in
|
||||
print("🏠 ONBOARDING: Task creation result: \(success ? "SUCCESS" : "FAILED")")
|
||||
self.isCreatingTask = false
|
||||
self.onTaskAdded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task Template Model
|
||||
|
||||
struct TaskTemplate: Identifiable {
|
||||
let id = UUID()
|
||||
let icon: String
|
||||
let title: String
|
||||
let category: String
|
||||
let frequency: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
// MARK: - Task Template Card
|
||||
|
||||
struct TaskTemplateCard: View {
|
||||
let template: TaskTemplate
|
||||
let isSelected: Bool
|
||||
var onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(template.color.opacity(0.1))
|
||||
.frame(width: 56, height: 56)
|
||||
|
||||
Image(systemName: template.icon)
|
||||
.font(.title2)
|
||||
.foregroundColor(template.color)
|
||||
}
|
||||
|
||||
Text(template.title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(template.frequency.capitalized)
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(AppSpacing.md)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppRadius.lg)
|
||||
.stroke(isSelected ? template.color : Color.clear, lineWidth: 2)
|
||||
)
|
||||
.shadow(color: isSelected ? template.color.opacity(0.2) : .clear, radius: 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
// MARK: - Legacy wrapper with navigation bar (for backwards compatibility)
|
||||
|
||||
struct OnboardingFirstTaskView: View {
|
||||
var residenceName: String
|
||||
var onTaskAdded: () -> Void
|
||||
var onSkip: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Navigation bar
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button(action: onSkip) {
|
||||
Text("Skip")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.lg)
|
||||
.padding(.vertical, AppSpacing.md)
|
||||
|
||||
OnboardingFirstTaskContent(
|
||||
residenceName: residenceName,
|
||||
onTaskAdded: onTaskAdded
|
||||
)
|
||||
}
|
||||
.background(Color.appBackgroundPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OnboardingFirstTaskContent(
|
||||
residenceName: "My Home",
|
||||
onTaskAdded: {}
|
||||
)
|
||||
}
|
||||
202
iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift
Normal file
202
iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift
Normal file
@@ -0,0 +1,202 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
/// Screen for joining an existing residence with a share code - Content only (no navigation bar)
|
||||
struct OnboardingJoinResidenceContent: View {
|
||||
var onJoined: () -> Void
|
||||
|
||||
@StateObject private var viewModel = ResidenceViewModel()
|
||||
@State private var shareCode: String = ""
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@FocusState private var isCodeFieldFocused: Bool
|
||||
|
||||
private var isCodeValid: Bool {
|
||||
shareCode.count == 6
|
||||
}
|
||||
|
||||
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: "person.2.badge.key.fill")
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(Color.appPrimary.gradient)
|
||||
}
|
||||
|
||||
// Title
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
Text("Join a Residence")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text("Enter the 6-character code shared with you to join an existing home.")
|
||||
.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 share code", text: $shareCode)
|
||||
.textInputAutocapitalization(.characters)
|
||||
.autocorrectionDisabled()
|
||||
.focused($isCodeFieldFocused)
|
||||
.onChange(of: shareCode) { _, newValue in
|
||||
// Limit to 6 characters
|
||||
if newValue.count > 6 {
|
||||
shareCode = String(newValue.prefix(6))
|
||||
}
|
||||
// Clear error when typing
|
||||
errorMessage = nil
|
||||
}
|
||||
}
|
||||
.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 = 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 isLoading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Joining residence...")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Join button
|
||||
Button(action: joinResidence) {
|
||||
Text("Join Residence")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.background(
|
||||
isCodeValid && !isLoading
|
||||
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
: AnyShapeStyle(Color.appTextSecondary)
|
||||
)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: isCodeValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
|
||||
}
|
||||
.disabled(!isCodeValid || isLoading)
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
.padding(.bottom, AppSpacing.xxxl)
|
||||
}
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
isCodeFieldFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func joinResidence() {
|
||||
guard shareCode.count == 6 else {
|
||||
errorMessage = "Share code must be 6 characters"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
// Call the shared ViewModel which uses APILayer
|
||||
await viewModel.sharedViewModel.joinWithCode(code: shareCode)
|
||||
|
||||
// Observe the result
|
||||
for await state in viewModel.sharedViewModel.joinResidenceState {
|
||||
if state is ApiResultSuccess<JoinResidenceResponse> {
|
||||
await MainActor.run {
|
||||
viewModel.sharedViewModel.resetJoinResidenceState()
|
||||
isLoading = false
|
||||
onJoined()
|
||||
}
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
errorMessage = ErrorMessageParser.parse(error.message)
|
||||
viewModel.sharedViewModel.resetJoinResidenceState()
|
||||
isLoading = false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
// MARK: - Legacy wrapper with navigation bar (for backwards compatibility)
|
||||
|
||||
struct OnboardingJoinResidenceView: View {
|
||||
var onJoined: () -> Void
|
||||
var onSkip: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Navigation bar
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button(action: onSkip) {
|
||||
Text("Skip")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.lg)
|
||||
.padding(.vertical, AppSpacing.md)
|
||||
|
||||
OnboardingJoinResidenceContent(onJoined: onJoined)
|
||||
}
|
||||
.background(Color.appBackgroundPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OnboardingJoinResidenceContent(onJoined: {})
|
||||
}
|
||||
154
iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift
Normal file
154
iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift
Normal file
@@ -0,0 +1,154 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Screen 2: Name your home - Content only (no navigation bar)
|
||||
struct OnboardingNameResidenceContent: View {
|
||||
@Binding var residenceName: String
|
||||
var onContinue: () -> Void
|
||||
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
|
||||
private var isValid: Bool {
|
||||
!residenceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
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("icon")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 50, height: 50)
|
||||
}
|
||||
|
||||
// Title
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
Text("What should we call your place?")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("You can always change this later")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
|
||||
// Text field
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xs) {
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
Image(systemName: "pencil")
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.frame(width: 20)
|
||||
|
||||
TextField("My Home", text: $residenceName)
|
||||
.font(.body)
|
||||
.textInputAutocapitalization(.words)
|
||||
.focused($isTextFieldFocused)
|
||||
.submitLabel(.continue)
|
||||
.onSubmit {
|
||||
if isValid {
|
||||
onContinue()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppRadius.md)
|
||||
.stroke(isTextFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
|
||||
)
|
||||
.shadow(color: isTextFieldFocused ? Color.appPrimary.opacity(0.1) : .clear, radius: 8)
|
||||
.animation(.easeInOut(duration: 0.2), value: isTextFieldFocused)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Continue button
|
||||
Button(action: onContinue) {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.background(
|
||||
isValid
|
||||
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
: AnyShapeStyle(Color.appTextSecondary)
|
||||
)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: isValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
|
||||
}
|
||||
.disabled(!isValid)
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
.padding(.bottom, AppSpacing.xxxl)
|
||||
}
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
isTextFieldFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy wrapper with navigation bar (for backwards compatibility)
|
||||
|
||||
struct OnboardingNameResidenceView: View {
|
||||
@Binding var residenceName: String
|
||||
var onContinue: () -> 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: 1, totalSteps: 5)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Invisible spacer for alignment
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.title2)
|
||||
.opacity(0)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.lg)
|
||||
.padding(.vertical, AppSpacing.md)
|
||||
|
||||
OnboardingNameResidenceContent(
|
||||
residenceName: $residenceName,
|
||||
onContinue: onContinue
|
||||
)
|
||||
}
|
||||
.background(Color.appBackgroundPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
OnboardingNameResidenceContent(
|
||||
residenceName: .constant(""),
|
||||
onContinue: {}
|
||||
)
|
||||
}
|
||||
132
iosApp/iosApp/Onboarding/OnboardingState.swift
Normal file
132
iosApp/iosApp/Onboarding/OnboardingState.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
/// User's intent during onboarding
|
||||
enum OnboardingIntent: String {
|
||||
case unknown
|
||||
case startFresh // Creating a new residence
|
||||
case joinExisting // Joining with a share code
|
||||
}
|
||||
|
||||
/// Manages the state of the onboarding flow
|
||||
class OnboardingState: ObservableObject {
|
||||
static let shared = OnboardingState()
|
||||
|
||||
/// Whether the user has completed onboarding
|
||||
@AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false
|
||||
|
||||
/// The name of the residence being created during onboarding
|
||||
@AppStorage("onboardingResidenceName") var pendingResidenceName: String = ""
|
||||
|
||||
/// The user's selected intent (start fresh or join existing) - persisted
|
||||
@AppStorage("onboardingUserIntent") private var userIntentRaw: String = OnboardingIntent.unknown.rawValue
|
||||
|
||||
/// The user's selected intent (start fresh or join existing)
|
||||
var userIntent: OnboardingIntent {
|
||||
get { OnboardingIntent(rawValue: userIntentRaw) ?? .unknown }
|
||||
set {
|
||||
userIntentRaw = newValue.rawValue
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
/// Current step in the onboarding flow
|
||||
@Published var currentStep: OnboardingStep = .welcome
|
||||
|
||||
/// Whether onboarding is currently active
|
||||
@Published var isOnboardingActive: Bool = false
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Start the onboarding flow
|
||||
func startOnboarding() {
|
||||
isOnboardingActive = true
|
||||
currentStep = .welcome
|
||||
userIntent = .unknown
|
||||
}
|
||||
|
||||
/// Move to the next step in the flow
|
||||
func nextStep() {
|
||||
switch currentStep {
|
||||
case .welcome:
|
||||
if userIntent == .joinExisting {
|
||||
currentStep = .createAccount
|
||||
} else {
|
||||
currentStep = .nameResidence
|
||||
}
|
||||
case .nameResidence:
|
||||
currentStep = .valueProps
|
||||
case .valueProps:
|
||||
currentStep = .createAccount
|
||||
case .createAccount:
|
||||
currentStep = .verifyEmail
|
||||
case .verifyEmail:
|
||||
if userIntent == .joinExisting {
|
||||
currentStep = .joinResidence
|
||||
} else {
|
||||
currentStep = .firstTask
|
||||
}
|
||||
case .joinResidence:
|
||||
currentStep = .subscriptionUpsell
|
||||
case .firstTask:
|
||||
currentStep = .subscriptionUpsell
|
||||
case .subscriptionUpsell:
|
||||
completeOnboarding()
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip to a specific step
|
||||
func skipTo(_ step: OnboardingStep) {
|
||||
currentStep = step
|
||||
}
|
||||
|
||||
/// Complete the onboarding flow
|
||||
func completeOnboarding() {
|
||||
hasCompletedOnboarding = true
|
||||
isOnboardingActive = false
|
||||
pendingResidenceName = ""
|
||||
userIntent = .unknown
|
||||
}
|
||||
|
||||
/// Reset onboarding state (useful for testing or re-onboarding)
|
||||
func reset() {
|
||||
hasCompletedOnboarding = false
|
||||
isOnboardingActive = false
|
||||
pendingResidenceName = ""
|
||||
userIntent = .unknown
|
||||
currentStep = .welcome
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps in the onboarding flow
|
||||
enum OnboardingStep: Int, CaseIterable {
|
||||
case welcome = 0
|
||||
case nameResidence = 1
|
||||
case valueProps = 2
|
||||
case createAccount = 3
|
||||
case verifyEmail = 4
|
||||
case joinResidence = 5 // Only for users joining with a code
|
||||
case firstTask = 6
|
||||
case subscriptionUpsell = 7
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .welcome:
|
||||
return "Welcome"
|
||||
case .nameResidence:
|
||||
return "Name Your Home"
|
||||
case .valueProps:
|
||||
return "Features"
|
||||
case .createAccount:
|
||||
return "Create Account"
|
||||
case .verifyEmail:
|
||||
return "Verify Email"
|
||||
case .joinResidence:
|
||||
return "Join Residence"
|
||||
case .firstTask:
|
||||
return "First Task"
|
||||
case .subscriptionUpsell:
|
||||
return "Go Pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
236
iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift
Normal file
236
iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift
Normal file
@@ -0,0 +1,236 @@
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
/// Screen 7: Subscription upsell - Content only (no navigation bar)
|
||||
struct OnboardingSubscriptionContent: View {
|
||||
var onSubscribe: () -> Void
|
||||
|
||||
@State private var isLoading = false
|
||||
|
||||
private let benefits: [SubscriptionBenefit] = [
|
||||
SubscriptionBenefit(
|
||||
icon: "building.2.fill",
|
||||
title: "Unlimited Properties",
|
||||
description: "Manage multiple homes, rentals, or vacation properties"
|
||||
),
|
||||
SubscriptionBenefit(
|
||||
icon: "checklist",
|
||||
title: "Unlimited Tasks",
|
||||
description: "Track as many maintenance items as you need"
|
||||
),
|
||||
SubscriptionBenefit(
|
||||
icon: "doc.fill",
|
||||
title: "Warranty Storage",
|
||||
description: "Keep all your warranty documents in one place"
|
||||
),
|
||||
SubscriptionBenefit(
|
||||
icon: "person.2.fill",
|
||||
title: "Household Sharing",
|
||||
description: "Invite family members to collaborate on tasks"
|
||||
)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppSpacing.xl) {
|
||||
// Header
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
// Pro badge
|
||||
HStack(spacing: AppSpacing.xs) {
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundColor(Color.appAccent)
|
||||
Text("PRO")
|
||||
.font(.headline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appAccent)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
.padding(.vertical, AppSpacing.xs)
|
||||
.background(Color.appAccent.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
|
||||
Text("Unlock the full power of Casera")
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Get more done with Pro features")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(.top, AppSpacing.xxl)
|
||||
|
||||
// Benefits list
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
ForEach(benefits) { benefit in
|
||||
SubscriptionBenefitRow(benefit: benefit)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.lg)
|
||||
|
||||
// Pricing card
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
Text("Monthly")
|
||||
.font(.headline)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Text("Cancel anytime")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: AppSpacing.xxs) {
|
||||
Text("$4.99")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
Text("/month")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.lg)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppRadius.lg)
|
||||
.stroke(Color.appPrimary, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.lg)
|
||||
|
||||
// CTA buttons
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
Button(action: startFreeTrial) {
|
||||
HStack {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text("Start Free Trial")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5)
|
||||
}
|
||||
.disabled(isLoading)
|
||||
|
||||
// Legal text
|
||||
Text("7-day free trial, then $4.99/month. Cancel anytime in Settings.")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, AppSpacing.xs)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
.padding(.bottom, AppSpacing.xxxl)
|
||||
}
|
||||
}
|
||||
.background(Color.appBackgroundPrimary)
|
||||
}
|
||||
|
||||
private func startFreeTrial() {
|
||||
isLoading = true
|
||||
|
||||
// Initiate StoreKit purchase flow
|
||||
Task {
|
||||
do {
|
||||
// This would integrate with your StoreKitManager
|
||||
// For now, we'll simulate the flow
|
||||
try await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
onSubscribe()
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
// Handle error - still proceed (they can subscribe later)
|
||||
onSubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy wrapper with navigation bar (for backwards compatibility)
|
||||
|
||||
struct OnboardingSubscriptionView: View {
|
||||
var onSubscribe: () -> Void
|
||||
var onSkip: () -> Void
|
||||
|
||||
var body: some View {
|
||||
OnboardingSubscriptionContent(onSubscribe: onSubscribe)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription Benefit Model
|
||||
|
||||
struct SubscriptionBenefit: Identifiable {
|
||||
let id = UUID()
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
}
|
||||
|
||||
// MARK: - Subscription Benefit Row
|
||||
|
||||
struct SubscriptionBenefitRow: View {
|
||||
let benefit: SubscriptionBenefit
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: AppSpacing.md) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: benefit.icon)
|
||||
.font(.title3)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
Text(benefit.title)
|
||||
.font(.headline)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text(benefit.description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.lg)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
OnboardingSubscriptionContent(onSubscribe: {})
|
||||
}
|
||||
174
iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift
Normal file
174
iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift
Normal file
@@ -0,0 +1,174 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Screen 3: Swipeable value propositions carousel - Content only (no navigation bar)
|
||||
struct OnboardingValuePropsContent: View {
|
||||
var onContinue: () -> Void
|
||||
|
||||
@State private var currentPage = 0
|
||||
|
||||
private let valueProps: [ValueProp] = [
|
||||
ValueProp(
|
||||
icon: "checklist",
|
||||
title: "Track Maintenance Tasks",
|
||||
description: "Never forget when the furnace filter is due. Set one-time or recurring tasks and get reminders.",
|
||||
color: Color.appPrimary
|
||||
),
|
||||
ValueProp(
|
||||
icon: "person.text.rectangle",
|
||||
title: "Save Contractor Info",
|
||||
description: "Keep your trusted pros in one place. No more digging for business cards or phone numbers.",
|
||||
color: Color.appSecondary
|
||||
),
|
||||
ValueProp(
|
||||
icon: "person.2.fill",
|
||||
title: "Share with Family",
|
||||
description: "Get the whole household on the same page. Everyone can see what's due and mark tasks complete.",
|
||||
color: Color.appAccent
|
||||
)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
// Carousel
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(Array(valueProps.enumerated()), id: \.offset) { index, prop in
|
||||
ValuePropCard(prop: prop)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.frame(height: 400)
|
||||
|
||||
// Page indicator
|
||||
HStack(spacing: AppSpacing.xs) {
|
||||
ForEach(0..<valueProps.count, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index == currentPage ? Color.appPrimary : Color.appTextSecondary.opacity(0.3))
|
||||
.frame(width: 8, height: 8)
|
||||
.animation(.easeInOut(duration: 0.2), value: currentPage)
|
||||
}
|
||||
}
|
||||
.padding(.top, AppSpacing.lg)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Continue button
|
||||
Button(action: onContinue) {
|
||||
Text("Get Started")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
.padding(.bottom, AppSpacing.xxxl)
|
||||
}
|
||||
.background(Color.appBackgroundPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Value Prop Model
|
||||
|
||||
struct ValueProp: Identifiable {
|
||||
let id = UUID()
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
// MARK: - Value Prop Card
|
||||
|
||||
struct ValuePropCard: View {
|
||||
let prop: ValueProp
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: AppSpacing.xl) {
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(prop.color.opacity(0.1))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: prop.icon)
|
||||
.font(.system(size: 50))
|
||||
.foregroundStyle(prop.color.gradient)
|
||||
}
|
||||
|
||||
// Text
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
Text(prop.title)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(prop.description)
|
||||
.font(.body)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.lg)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy wrapper with navigation bar (for backwards compatibility)
|
||||
|
||||
struct OnboardingValuePropsView: View {
|
||||
var onContinue: () -> Void
|
||||
var onSkip: () -> 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: 2, totalSteps: 5)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onSkip) {
|
||||
Text("Skip")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.lg)
|
||||
.padding(.vertical, AppSpacing.md)
|
||||
|
||||
OnboardingValuePropsContent(onContinue: onContinue)
|
||||
}
|
||||
.background(Color.appBackgroundPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
OnboardingValuePropsContent(onContinue: {})
|
||||
}
|
||||
192
iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift
Normal file
192
iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift
Normal file
@@ -0,0 +1,192 @@
|
||||
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)
|
||||
|
||||
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)
|
||||
.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)
|
||||
}
|
||||
.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: {})
|
||||
}
|
||||
120
iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift
Normal file
120
iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Screen 1: Welcome screen with Start Fresh / Join Existing options
|
||||
struct OnboardingWelcomeView: View {
|
||||
var onStartFresh: () -> Void
|
||||
var onJoinExisting: () -> Void
|
||||
var onLogin: () -> Void
|
||||
|
||||
@State private var showingLoginSheet = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
// Hero section
|
||||
VStack(spacing: AppSpacing.xl) {
|
||||
// App icon
|
||||
Image("icon")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 120, height: 120)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.xxl))
|
||||
.shadow(color: Color.appPrimary.opacity(0.3), radius: 20, y: 10)
|
||||
|
||||
// Welcome text
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
Text("Welcome to Casera")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text("Your home maintenance companion")
|
||||
.font(.title3)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Action buttons
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
// Primary CTA - Start Fresh
|
||||
Button(action: onStartFresh) {
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
Image("icon")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
Text("Start Fresh")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5)
|
||||
}
|
||||
|
||||
// Secondary CTA - Join Existing
|
||||
Button(action: onJoinExisting) {
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.title3)
|
||||
Text("I have a code to join")
|
||||
.font(.headline)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
|
||||
// Returning user login
|
||||
Button(action: {
|
||||
showingLoginSheet = true
|
||||
}) {
|
||||
Text("Already have an account? Log in")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(.top, AppSpacing.sm)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
.padding(.bottom, AppSpacing.xxxl)
|
||||
}
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.sheet(isPresented: $showingLoginSheet) {
|
||||
LoginView(onLoginSuccess: {
|
||||
showingLoginSheet = false
|
||||
onLogin()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content-only version (no navigation bar)
|
||||
|
||||
/// Content-only version for use in coordinator
|
||||
typealias OnboardingWelcomeContent = OnboardingWelcomeView
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
OnboardingWelcomeView(
|
||||
onStartFresh: {},
|
||||
onJoinExisting: {},
|
||||
onLogin: {}
|
||||
)
|
||||
}
|
||||
@@ -104,14 +104,23 @@ class AuthenticationManager: ObservableObject {
|
||||
isAuthenticated = false
|
||||
isVerified = false
|
||||
|
||||
// Note: We don't reset onboarding state on logout
|
||||
// so returning users go to login screen, not onboarding
|
||||
|
||||
print("AuthenticationManager: Logged out - all state reset")
|
||||
}
|
||||
|
||||
/// Reset onboarding state (for testing or re-onboarding)
|
||||
func resetOnboarding() {
|
||||
OnboardingState.shared.reset()
|
||||
}
|
||||
}
|
||||
|
||||
/// Root view that handles authentication flow: loading -> login -> verify email -> main app
|
||||
/// Root view that handles authentication flow: loading -> onboarding -> login -> verify email -> main app
|
||||
struct RootView: View {
|
||||
@EnvironmentObject private var themeManager: ThemeManager
|
||||
@StateObject private var authManager = AuthenticationManager.shared
|
||||
@StateObject private var onboardingState = OnboardingState.shared
|
||||
@State private var refreshID = UUID()
|
||||
|
||||
var body: some View {
|
||||
@@ -119,11 +128,19 @@ struct RootView: View {
|
||||
if authManager.isCheckingAuth {
|
||||
// Show loading while checking auth status
|
||||
loadingView
|
||||
} else if !onboardingState.hasCompletedOnboarding {
|
||||
// Show onboarding for first-time users (includes auth + verification steps)
|
||||
// This takes precedence because we need to finish the onboarding flow
|
||||
OnboardingCoordinator(onComplete: {
|
||||
// Onboarding complete - mark verified and refresh the view
|
||||
authManager.markVerified()
|
||||
refreshID = UUID()
|
||||
})
|
||||
} else if !authManager.isAuthenticated {
|
||||
// Show login screen
|
||||
// Show login screen for returning users
|
||||
LoginView()
|
||||
} else if !authManager.isVerified {
|
||||
// Show email verification screen
|
||||
// Show email verification screen (for returning users who haven't verified)
|
||||
VerifyEmailView(
|
||||
onVerifySuccess: {
|
||||
authManager.markVerified()
|
||||
|
||||
@@ -45,9 +45,12 @@ class VerifyEmailViewModel: ObservableObject {
|
||||
sharedViewModel.verifyEmailState,
|
||||
onLoading: { [weak self] in self?.isLoading = true },
|
||||
onSuccess: { [weak self] (response: VerifyEmailResponse) in
|
||||
print("🏠 VerifyEmailViewModel: onSuccess called, verified=\(response.verified)")
|
||||
if response.verified {
|
||||
print("🏠 VerifyEmailViewModel: Setting isVerified = true")
|
||||
self?.isVerified = true
|
||||
self?.isLoading = false
|
||||
print("🏠 VerifyEmailViewModel: isVerified is now \(self?.isVerified ?? false)")
|
||||
} else {
|
||||
self?.errorMessage = "Verification failed"
|
||||
self?.isLoading = false
|
||||
|
||||
Reference in New Issue
Block a user