New framework: - AccessibilityLabels.swift: centralized A11y struct with VoiceOver strings - AccessibilityModifiers.swift: reusable .a11yHeader, .a11yDecorative, .a11yButton, .a11yCard, .a11yStatValue View extensions Shared components: decorative elements hidden, stat views combined, status/priority badges labeled, error views announced, empty states grouped Cards: ResidenceCard, TaskCard, DynamicTaskCard, ContractorCard, DocumentCard, WarrantyCard — all grouped with combined labels, chevrons hidden, action buttons labeled Main screens: Login, Register, Residences, Tasks, Contractors, Documents — toolbar buttons labeled, section headers marked, form field hints added Onboarding: all 10 views — header traits, button hints, task selection state, progress indicator, decorative backgrounds hidden Profile/Subscription: toggle hints, theme selection state, feature comparison table accessibility, subscription button labels iOS build verified: BUILD SUCCEEDED
372 lines
14 KiB
Swift
372 lines
14 KiB
Swift
import SwiftUI
|
|
import ComposeApp
|
|
|
|
/// Coordinates the onboarding flow, presenting the appropriate view based on current step
|
|
struct OnboardingCoordinator: View {
|
|
@ObservedObject private var onboardingState = OnboardingState.shared
|
|
@StateObject private var residenceViewModel = ResidenceViewModel()
|
|
@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) {
|
|
isNavigatingBack = true
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
onboardingState.currentStep = step
|
|
} completion: {
|
|
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 and postal code 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
|
|
}
|
|
|
|
let postalCode = onboardingState.pendingPostalCode.isEmpty ? nil : onboardingState.pendingPostalCode
|
|
print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName), zip: \(postalCode ?? "none")")
|
|
|
|
isCreatingResidence = true
|
|
|
|
let request = ResidenceCreateRequest(
|
|
name: onboardingState.pendingResidenceName,
|
|
propertyTypeId: nil,
|
|
streetAddress: nil,
|
|
apartmentUnit: nil,
|
|
city: nil,
|
|
stateProvince: nil,
|
|
postalCode: postalCode,
|
|
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) { (residence: ResidenceResponse?) in
|
|
self.isCreatingResidence = false
|
|
if let residence = residence {
|
|
print("🏠 ONBOARDING: Residence created successfully with ID: \(residence.id)")
|
|
self.onboardingState.createdResidenceId = residence.id
|
|
} else {
|
|
print("🏠 ONBOARDING: Residence creation FAILED")
|
|
}
|
|
// Navigate regardless of success - user can create residence later if needed
|
|
self.goForward(to: step)
|
|
}
|
|
}
|
|
|
|
/// Current step index for progress indicator (0-based)
|
|
/// Flow: Welcome → Features → Name → Account → Verify → Location → Tasks → Upsell
|
|
private var currentProgressStep: Int {
|
|
switch onboardingState.currentStep {
|
|
case .welcome: return 0
|
|
case .valueProps: return 1
|
|
case .nameResidence: return 2
|
|
case .createAccount: return 3
|
|
case .verifyEmail: return 4
|
|
case .joinResidence: return 4
|
|
case .residenceLocation: 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, .residenceLocation, .firstTask, .subscriptionUpsell:
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// Whether to show the skip button
|
|
private var showSkipButton: Bool {
|
|
switch onboardingState.currentStep {
|
|
case .valueProps, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Whether to show the progress indicator
|
|
private var showProgressIndicator: Bool {
|
|
switch onboardingState.currentStep {
|
|
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
private func handleBack() {
|
|
// Flow: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell
|
|
switch onboardingState.currentStep {
|
|
case .valueProps:
|
|
goBack(to: .welcome)
|
|
case .nameResidence:
|
|
goBack(to: .valueProps)
|
|
case .createAccount:
|
|
if onboardingState.userIntent == .joinExisting {
|
|
goBack(to: .welcome)
|
|
} else {
|
|
goBack(to: .nameResidence)
|
|
}
|
|
case .verifyEmail:
|
|
AuthenticationManager.shared.logout()
|
|
goBack(to: .createAccount)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func handleSkip() {
|
|
switch onboardingState.currentStep {
|
|
case .valueProps:
|
|
goForward()
|
|
case .residenceLocation:
|
|
// Skipping location — still need to create residence (without postal code)
|
|
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
|
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 — fixed width so progress dots stay centered
|
|
Button(action: handleBack) {
|
|
Image(systemName: "chevron.left")
|
|
.font(.title2)
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.backButton)
|
|
.accessibilityLabel("Go back")
|
|
.frame(width: 44, alignment: .leading)
|
|
.opacity(showBackButton ? 1 : 0)
|
|
.disabled(!showBackButton)
|
|
|
|
Spacer()
|
|
|
|
// Progress indicator
|
|
if showProgressIndicator {
|
|
OnboardingProgressIndicator(currentStep: currentProgressStep, totalSteps: 5)
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.progressIndicator)
|
|
.accessibilityLabel("Step \(currentProgressStep + 1) of 5")
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Skip button — fixed width to match back button
|
|
Button(action: handleSkip) {
|
|
Text("Skip")
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.skipButton)
|
|
.accessibilityLabel("Skip")
|
|
.frame(width: 44, alignment: .trailing)
|
|
.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 {
|
|
goForward(to: .residenceLocation)
|
|
}
|
|
} else {
|
|
goForward()
|
|
}
|
|
}
|
|
)
|
|
.transition(navigationTransition)
|
|
|
|
case .verifyEmail:
|
|
OnboardingVerifyEmailContent(
|
|
onVerified: {
|
|
print("🏠 ONBOARDING: onVerified callback triggered in coordinator")
|
|
if onboardingState.userIntent == .joinExisting {
|
|
goForward(to: .joinResidence)
|
|
} else {
|
|
goForward(to: .residenceLocation)
|
|
}
|
|
}
|
|
)
|
|
.transition(navigationTransition)
|
|
|
|
case .joinResidence:
|
|
OnboardingJoinResidenceContent(
|
|
onJoined: {
|
|
goForward()
|
|
}
|
|
)
|
|
.transition(navigationTransition)
|
|
|
|
case .residenceLocation:
|
|
OnboardingLocationContent(
|
|
onLocationDetected: { zip in
|
|
// Load regional templates in background while creating residence
|
|
onboardingState.loadRegionalTemplates(zip: zip)
|
|
// Create residence with postal code, then go to first task
|
|
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
|
},
|
|
onSkip: {
|
|
// Handled by handleSkip() above
|
|
}
|
|
)
|
|
.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: {})
|
|
}
|