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:
Trey t
2025-12-02 11:00:51 -06:00
parent db65fe125b
commit 0652908c20
12 changed files with 2245 additions and 3 deletions

View 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: {})
}