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: {})
|
||||||
|
}
|
||||||
348
iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift
Normal file
348
iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift
Normal 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 })
|
||||||
|
}
|
||||||
320
iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift
Normal file
320
iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift
Normal 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: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
202
iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift
Normal file
202
iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift
Normal 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: {})
|
||||||
|
}
|
||||||
154
iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift
Normal file
154
iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift
Normal 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: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
132
iosApp/iosApp/Onboarding/OnboardingState.swift
Normal file
132
iosApp/iosApp/Onboarding/OnboardingState.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
236
iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift
Normal file
236
iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift
Normal 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: {})
|
||||||
|
}
|
||||||
174
iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift
Normal file
174
iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift
Normal 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: {})
|
||||||
|
}
|
||||||
192
iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift
Normal file
192
iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift
Normal 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: {})
|
||||||
|
}
|
||||||
120
iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift
Normal file
120
iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift
Normal 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: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -104,14 +104,23 @@ class AuthenticationManager: ObservableObject {
|
|||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
isVerified = 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")
|
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 {
|
struct RootView: View {
|
||||||
@EnvironmentObject private var themeManager: ThemeManager
|
@EnvironmentObject private var themeManager: ThemeManager
|
||||||
@StateObject private var authManager = AuthenticationManager.shared
|
@StateObject private var authManager = AuthenticationManager.shared
|
||||||
|
@StateObject private var onboardingState = OnboardingState.shared
|
||||||
@State private var refreshID = UUID()
|
@State private var refreshID = UUID()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -119,11 +128,19 @@ struct RootView: View {
|
|||||||
if authManager.isCheckingAuth {
|
if authManager.isCheckingAuth {
|
||||||
// Show loading while checking auth status
|
// Show loading while checking auth status
|
||||||
loadingView
|
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 {
|
} else if !authManager.isAuthenticated {
|
||||||
// Show login screen
|
// Show login screen for returning users
|
||||||
LoginView()
|
LoginView()
|
||||||
} else if !authManager.isVerified {
|
} else if !authManager.isVerified {
|
||||||
// Show email verification screen
|
// Show email verification screen (for returning users who haven't verified)
|
||||||
VerifyEmailView(
|
VerifyEmailView(
|
||||||
onVerifySuccess: {
|
onVerifySuccess: {
|
||||||
authManager.markVerified()
|
authManager.markVerified()
|
||||||
|
|||||||
@@ -45,9 +45,12 @@ class VerifyEmailViewModel: ObservableObject {
|
|||||||
sharedViewModel.verifyEmailState,
|
sharedViewModel.verifyEmailState,
|
||||||
onLoading: { [weak self] in self?.isLoading = true },
|
onLoading: { [weak self] in self?.isLoading = true },
|
||||||
onSuccess: { [weak self] (response: VerifyEmailResponse) in
|
onSuccess: { [weak self] (response: VerifyEmailResponse) in
|
||||||
|
print("🏠 VerifyEmailViewModel: onSuccess called, verified=\(response.verified)")
|
||||||
if response.verified {
|
if response.verified {
|
||||||
|
print("🏠 VerifyEmailViewModel: Setting isVerified = true")
|
||||||
self?.isVerified = true
|
self?.isVerified = true
|
||||||
self?.isLoading = false
|
self?.isLoading = false
|
||||||
|
print("🏠 VerifyEmailViewModel: isVerified is now \(self?.isVerified ?? false)")
|
||||||
} else {
|
} else {
|
||||||
self?.errorMessage = "Verification failed"
|
self?.errorMessage = "Verification failed"
|
||||||
self?.isLoading = false
|
self?.isLoading = false
|
||||||
|
|||||||
Reference in New Issue
Block a user