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

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

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

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

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

View 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"
}
}
}

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

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

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

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

View File

@@ -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()

View File

@@ -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