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: {})
|
||||
}
|
||||
Reference in New Issue
Block a user