Files
honeyDueKMP/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift
Trey T af73f8861b iOS VoiceOver accessibility overhaul — 67 files
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
2026-03-26 14:51:29 -05:00

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