diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 99862c0..92a57eb 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -713,6 +713,15 @@ Home Screen Widgets Quick access to tasks and reminders directly from your home screen + + Where is your home? + We\'ll suggest maintenance tasks specific to your area\'s climate + Use My Location + Detecting... + Enter ZIP code instead + Enter your ZIP code + ZIP Code + Name Your Home Give your property a name to help you identify it diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt index ca6cd2b..45b558c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -1192,6 +1192,15 @@ object APILayer { } ?: ApiResult.Error("Task template not found") } + /** + * Get task templates filtered by climate region. + * Accepts either a state abbreviation or ZIP code — backend resolves to climate zone. + * This calls the API directly since regional templates are not cached in seeded data. + */ + suspend fun getRegionalTemplates(state: String? = null, zip: String? = null): ApiResult> { + return taskTemplateApi.getTemplatesByRegion(state = state, zip = zip) + } + // ==================== Auth Operations ==================== suspend fun login(request: LoginRequest): ApiResult { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt index f4bc03e..c190066 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt @@ -9,7 +9,7 @@ package com.example.casera.network */ object ApiConfig { // ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️ - val CURRENT_ENV = Environment.DEV + val CURRENT_ENV = Environment.LOCAL enum class Environment { LOCAL, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskTemplateApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskTemplateApi.kt index d0ea050..38209c2 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskTemplateApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskTemplateApi.kt @@ -84,6 +84,27 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) { } } + /** + * Get templates filtered by climate region. + * Accepts either a state abbreviation or ZIP code — backend resolves to climate zone. + */ + suspend fun getTemplatesByRegion(state: String? = null, zip: String? = null): ApiResult> { + return try { + val response = client.get("$baseUrl/tasks/templates/by-region/") { + state?.let { parameter("state", it) } + zip?.let { parameter("zip", it) } + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to fetch regional templates", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + /** * Get a single template by ID */ diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingLocationContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingLocationContent.kt new file mode 100644 index 0000000..a2a9427 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingLocationContent.kt @@ -0,0 +1,133 @@ +package com.example.casera.ui.screens.onboarding + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.example.casera.ui.theme.* +import com.example.casera.viewmodel.OnboardingViewModel +import casera.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +/** + * Onboarding step that collects the user's ZIP code to suggest + * region-specific maintenance tasks. The backend handles all + * ZIP → state → climate zone resolution. + */ +@Composable +fun OnboardingLocationContent( + viewModel: OnboardingViewModel, + onLocationDetected: (String) -> Unit, + onSkip: () -> Unit +) { + var zipCode by remember { mutableStateOf("") } + + WarmGradientBackground( + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = OrganicSpacing.xl), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + + // Header with icon + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg) + ) { + OrganicIconContainer( + icon = Icons.Default.LocationOn, + size = 100.dp, + iconSize = 50.dp, + contentDescription = null + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm) + ) { + Text( + text = stringResource(Res.string.onboarding_location_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(Res.string.onboarding_location_subtitle), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + + Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2)) + + // ZIP code input + Text( + text = stringResource(Res.string.onboarding_location_enter_zip_prompt), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(OrganicSpacing.md)) + + OutlinedTextField( + value = zipCode, + onValueChange = { newValue -> + // Only allow digits, max 5 characters + if (newValue.length <= 5 && newValue.all { it.isDigit() }) { + zipCode = newValue + } + }, + placeholder = { + Text( + stringResource(Res.string.onboarding_location_zip_placeholder), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + }, + modifier = Modifier.width(180.dp), + shape = RoundedCornerShape(OrganicRadius.md), + singleLine = true, + textStyle = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + ) + + Spacer(modifier = Modifier.weight(1f)) + + // Continue button — passes ZIP directly to backend for resolution + OrganicPrimaryButton( + text = stringResource(Res.string.onboarding_continue), + onClick = { onLocationDetected(zipCode) }, + modifier = Modifier.fillMaxWidth(), + enabled = zipCode.length == 5, + icon = Icons.Default.ArrowForward + ) + + Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingScreen.kt index bc48b9b..d1aec9e 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingScreen.kt @@ -104,7 +104,7 @@ fun OnboardingScreen( viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE) } else { viewModel.createResidence() - viewModel.goToStep(OnboardingStep.FIRST_TASK) + viewModel.goToStep(OnboardingStep.RESIDENCE_LOCATION) } } else { viewModel.nextStep() @@ -119,7 +119,7 @@ fun OnboardingScreen( viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE) } else { viewModel.createResidence() - viewModel.goToStep(OnboardingStep.FIRST_TASK) + viewModel.goToStep(OnboardingStep.RESIDENCE_LOCATION) } } ) @@ -129,6 +129,15 @@ fun OnboardingScreen( onJoined = { viewModel.nextStep() } ) + OnboardingStep.RESIDENCE_LOCATION -> OnboardingLocationContent( + viewModel = viewModel, + onLocationDetected = { zip -> + viewModel.loadRegionalTemplates(zip) + viewModel.nextStep() + }, + onSkip = { viewModel.nextStep() } + ) + OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent( viewModel = viewModel, onTasksAdded = { viewModel.nextStep() } @@ -154,6 +163,7 @@ private fun OnboardingNavigationBar( val showBackButton = when (currentStep) { OnboardingStep.WELCOME, OnboardingStep.JOIN_RESIDENCE, + OnboardingStep.RESIDENCE_LOCATION, OnboardingStep.FIRST_TASK, OnboardingStep.SUBSCRIPTION_UPSELL -> false else -> true @@ -162,6 +172,7 @@ private fun OnboardingNavigationBar( val showSkipButton = when (currentStep) { OnboardingStep.VALUE_PROPS, OnboardingStep.JOIN_RESIDENCE, + OnboardingStep.RESIDENCE_LOCATION, OnboardingStep.FIRST_TASK, OnboardingStep.SUBSCRIPTION_UPSELL -> true else -> false @@ -170,6 +181,7 @@ private fun OnboardingNavigationBar( val showProgressIndicator = when (currentStep) { OnboardingStep.WELCOME, OnboardingStep.JOIN_RESIDENCE, + OnboardingStep.RESIDENCE_LOCATION, OnboardingStep.FIRST_TASK, OnboardingStep.SUBSCRIPTION_UPSELL -> false else -> true @@ -182,6 +194,7 @@ private fun OnboardingNavigationBar( OnboardingStep.CREATE_ACCOUNT -> 3 OnboardingStep.VERIFY_EMAIL -> 4 OnboardingStep.JOIN_RESIDENCE -> 4 + OnboardingStep.RESIDENCE_LOCATION -> 4 OnboardingStep.FIRST_TASK -> 4 OnboardingStep.SUBSCRIPTION_UPSELL -> 4 } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/OnboardingViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/OnboardingViewModel.kt index b33728b..cf66d3e 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/OnboardingViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/OnboardingViewModel.kt @@ -8,6 +8,7 @@ import com.example.casera.models.LoginRequest import com.example.casera.models.RegisterRequest import com.example.casera.models.ResidenceCreateRequest import com.example.casera.models.TaskCreateRequest +import com.example.casera.models.TaskTemplate import com.example.casera.models.VerifyEmailRequest import com.example.casera.network.ApiResult import com.example.casera.network.APILayer @@ -35,6 +36,7 @@ enum class OnboardingStep { CREATE_ACCOUNT, VERIFY_EMAIL, JOIN_RESIDENCE, + RESIDENCE_LOCATION, FIRST_TASK, SUBSCRIPTION_UPSELL } @@ -80,6 +82,14 @@ class OnboardingViewModel : ViewModel() { private val _createTasksState = MutableStateFlow>(ApiResult.Idle) val createTasksState: StateFlow> = _createTasksState + // Regional templates state + private val _regionalTemplates = MutableStateFlow>>(ApiResult.Idle) + val regionalTemplates: StateFlow>> = _regionalTemplates + + // ZIP code entered during location step (persisted on residence) + private val _postalCode = MutableStateFlow("") + val postalCode: StateFlow = _postalCode + // Whether onboarding is complete private val _isComplete = MutableStateFlow(false) val isComplete: StateFlow = _isComplete @@ -116,10 +126,11 @@ class OnboardingViewModel : ViewModel() { if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) { OnboardingStep.JOIN_RESIDENCE } else { - OnboardingStep.FIRST_TASK + OnboardingStep.RESIDENCE_LOCATION } } OnboardingStep.JOIN_RESIDENCE -> OnboardingStep.SUBSCRIPTION_UPSELL + OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.FIRST_TASK OnboardingStep.FIRST_TASK -> OnboardingStep.SUBSCRIPTION_UPSELL OnboardingStep.SUBSCRIPTION_UPSELL -> { completeOnboarding() @@ -161,6 +172,7 @@ class OnboardingViewModel : ViewModel() { when (_currentStep.value) { OnboardingStep.VALUE_PROPS, OnboardingStep.JOIN_RESIDENCE, + OnboardingStep.RESIDENCE_LOCATION, OnboardingStep.FIRST_TASK -> nextStep() OnboardingStep.SUBSCRIPTION_UPSELL -> completeOnboarding() else -> {} @@ -250,7 +262,7 @@ class OnboardingViewModel : ViewModel() { apartmentUnit = null, city = null, stateProvince = null, - postalCode = null, + postalCode = _postalCode.value.takeIf { it.isNotBlank() }, country = null, bedrooms = null, bathrooms = null, @@ -315,6 +327,18 @@ class OnboardingViewModel : ViewModel() { } } + /** + * Load regional templates by ZIP code (backend resolves ZIP → state → climate zone). + * Also stores the ZIP code for later use when creating the residence. + */ + fun loadRegionalTemplates(zip: String) { + _postalCode.value = zip + viewModelScope.launch { + _regionalTemplates.value = ApiResult.Loading + _regionalTemplates.value = APILayer.getRegionalTemplates(zip = zip) + } + } + /** * Mark onboarding as complete */ @@ -336,6 +360,8 @@ class OnboardingViewModel : ViewModel() { _createResidenceState.value = ApiResult.Idle _joinResidenceState.value = ApiResult.Idle _createTasksState.value = ApiResult.Idle + _regionalTemplates.value = ApiResult.Idle + _postalCode.value = "" _isComplete.value = false } diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index 756db24..a00c8da 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -46,18 +46,6 @@ "comment" : "A message displayed when a contractor is successfully imported to the user's contacts. The placeholder is replaced with the name of the imported contractor.", "isCommentAutoGenerated" : true }, - "%@, %@ %@" : { - "comment" : "A label displaying the city, state, and postal code of the residence.", - "isCommentAutoGenerated" : true, - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@, %2$@ %3$@" - } - } - } - }, "%@: %@" : { "comment" : "An error message displayed when there was an issue loading tasks for a residence.", "isCommentAutoGenerated" : true, @@ -146,6 +134,9 @@ } } } + }, + "12345" : { + }, "ABC123" : { @@ -5264,6 +5255,9 @@ }, "CONFIRM PASSWORD" : { + }, + "Continue" : { + }, "Continue with Free" : { @@ -16992,6 +16986,9 @@ "Enter your email address and we'll send you a verification code" : { "comment" : "A description below the email input field, instructing the user to enter their email address to receive a password reset code.", "isCommentAutoGenerated" : true + }, + "Enter your ZIP code so we can suggest\nmaintenance tasks for your climate region." : { + }, "Error" : { "comment" : "The title of an alert that appears when there's an error.", @@ -17358,6 +17355,9 @@ }, "Help improve Casera by sharing anonymous usage data" : { + }, + "Here are tasks recommended for your area.\nPick the ones you'd like to track!" : { + }, "Hour" : { "comment" : "A picker for selecting an hour.", @@ -30125,6 +30125,9 @@ "Welcome to Your Space" : { "comment" : "A welcoming message displayed at the top of the \"Organic Empty Residences\" view.", "isCommentAutoGenerated" : true + }, + "Where's your home?" : { + }, "You now have access to %@." : { "comment" : "A message displayed when a user successfully imports a residence, indicating that they now have access to it. The argument is the name of the residence that was imported.", diff --git a/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift b/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift index e3758cc..c8d6fa7 100644 --- a/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift +++ b/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift @@ -52,7 +52,7 @@ struct OnboardingCoordinator: View { } } - /// Creates a residence with the pending name from onboarding, then calls completion + /// Creates a residence with the pending name and postal code from onboarding, then calls completion private func createResidenceIfNeeded(thenNavigateTo step: OnboardingStep) { print("🏠 ONBOARDING: createResidenceIfNeeded called") print("🏠 ONBOARDING: userIntent = \(onboardingState.userIntent)") @@ -66,7 +66,8 @@ struct OnboardingCoordinator: View { return } - print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName)") + let postalCode = onboardingState.pendingPostalCode.isEmpty ? nil : onboardingState.pendingPostalCode + print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName), zip: \(postalCode ?? "none")") isCreatingResidence = true @@ -77,7 +78,7 @@ struct OnboardingCoordinator: View { apartmentUnit: nil, city: nil, stateProvince: nil, - postalCode: nil, + postalCode: postalCode, country: nil, bedrooms: nil, bathrooms: nil, @@ -104,7 +105,7 @@ struct OnboardingCoordinator: View { } /// Current step index for progress indicator (0-based) - /// Flow: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell + /// Flow: Welcome → Features → Name → Account → Verify → Location → Tasks → Upsell private var currentProgressStep: Int { switch onboardingState.currentStep { case .welcome: return 0 @@ -113,6 +114,7 @@ struct OnboardingCoordinator: View { case .createAccount: return 3 case .verifyEmail: return 4 case .joinResidence: return 4 + case .residenceLocation: return 4 case .firstTask: return 4 case .subscriptionUpsell: return 4 } @@ -121,7 +123,7 @@ struct OnboardingCoordinator: View { /// Whether to show the back button private var showBackButton: Bool { switch onboardingState.currentStep { - case .welcome, .joinResidence, .firstTask, .subscriptionUpsell: + case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell: return false default: return true @@ -131,7 +133,7 @@ struct OnboardingCoordinator: View { /// Whether to show the skip button private var showSkipButton: Bool { switch onboardingState.currentStep { - case .valueProps, .joinResidence, .firstTask, .subscriptionUpsell: + case .valueProps, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell: return true default: return false @@ -141,7 +143,7 @@ struct OnboardingCoordinator: View { /// Whether to show the progress indicator private var showProgressIndicator: Bool { switch onboardingState.currentStep { - case .welcome, .joinResidence, .firstTask, .subscriptionUpsell: + case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell: return false default: return true @@ -173,6 +175,9 @@ struct OnboardingCoordinator: View { switch onboardingState.currentStep { case .valueProps: goForward() + case .residenceLocation: + // Skipping location — still need to create residence (without postal code) + createResidenceIfNeeded(thenNavigateTo: .firstTask) case .joinResidence, .firstTask: goForward() case .subscriptionUpsell: @@ -187,13 +192,14 @@ struct OnboardingCoordinator: View { VStack(spacing: 0) { // Shared navigation bar - stays static HStack { - // Back button + // Back button — fixed width so progress dots stay centered Button(action: handleBack) { Image(systemName: "chevron.left") .font(.title2) .foregroundColor(Color.appPrimary) } .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.backButton) + .frame(width: 44, alignment: .leading) .opacity(showBackButton ? 1 : 0) .disabled(!showBackButton) @@ -207,7 +213,7 @@ struct OnboardingCoordinator: View { Spacer() - // Skip button + // Skip button — fixed width to match back button Button(action: handleSkip) { Text("Skip") .font(.subheadline) @@ -215,6 +221,7 @@ struct OnboardingCoordinator: View { .foregroundColor(Color.appTextSecondary) } .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.skipButton) + .frame(width: 44, alignment: .trailing) .opacity(showSkipButton ? 1 : 0) .disabled(!showSkipButton) } @@ -268,7 +275,7 @@ struct OnboardingCoordinator: View { if onboardingState.userIntent == .joinExisting { goForward(to: .joinResidence) } else { - createResidenceIfNeeded(thenNavigateTo: .firstTask) + goForward(to: .residenceLocation) } } else { goForward() @@ -281,13 +288,10 @@ struct OnboardingCoordinator: View { 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) + goForward(to: .residenceLocation) } } ) @@ -301,6 +305,20 @@ struct OnboardingCoordinator: View { ) .transition(navigationTransition) + case .residenceLocation: + OnboardingLocationContent( + onLocationDetected: { zip in + // Load regional templates in background while creating residence + onboardingState.loadRegionalTemplates(zip: zip) + // Create residence with postal code, then go to first task + createResidenceIfNeeded(thenNavigateTo: .firstTask) + }, + onSkip: { + // Handled by handleSkip() above + } + ) + .transition(navigationTransition) + case .firstTask: OnboardingFirstTaskContent( residenceName: onboardingState.pendingResidenceName, diff --git a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift index a7837f1..ca1ae8a 100644 --- a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift @@ -11,14 +11,91 @@ struct OnboardingFirstTaskContent: View { @ObservedObject private var onboardingState = OnboardingState.shared @State private var selectedTasks: Set = [] @State private var isCreatingTasks = false - @State private var expandedCategory: String? = nil + @State private var expandedCategories: Set = [] @State private var isAnimating = false @Environment(\.colorScheme) var colorScheme /// Maximum tasks allowed for free tier (matches API TierLimits) private let maxTasksAllowed = 5 - private let taskCategories: [OnboardingTaskCategory] = [ + /// Category colors by name (used for both API and fallback templates) + private static let categoryColors: [String: Color] = [ + "hvac": .appPrimary, + "safety": .appError, + "plumbing": .appSecondary, + "landscaping": Color(hex: "#34C759") ?? .green, + "exterior": Color(hex: "#34C759") ?? .green, + "appliances": .appAccent, + "interior": Color(hex: "#AF52DE") ?? .purple, + "electrical": .appAccent, + ] + + /// Category icons by name + private static let categoryIcons: [String: String] = [ + "hvac": "thermometer.medium", + "safety": "shield.checkered", + "plumbing": "drop.fill", + "landscaping": "leaf.fill", + "exterior": "leaf.fill", + "appliances": "refrigerator.fill", + "interior": "house.fill", + "electrical": "bolt.fill", + ] + + /// Cached categories — computed once and stored to preserve stable UUIDs + @State private var taskCategoriesCache: [OnboardingTaskCategory]? = nil + + /// Uses API-driven regional templates when available, falls back to hardcoded defaults + private var taskCategories: [OnboardingTaskCategory] { + if let cached = taskCategoriesCache { return cached } + if !onboardingState.regionalTemplates.isEmpty { + return categoriesFromAPI(onboardingState.regionalTemplates) + } + return fallbackCategories + } + + /// Convert API TaskTemplate list into OnboardingTaskCategory groups + private func categoriesFromAPI(_ templates: [TaskTemplate]) -> [OnboardingTaskCategory] { + // Group by category name + var grouped: [String: [OnboardingTaskTemplate]] = [:] + var order: [String] = [] + + for template in templates { + let catName = template.categoryName + let catKey = catName.lowercased() + let color = Self.categoryColors[catKey] ?? .appPrimary + let icon = template.iconIos.isEmpty ? "wrench.fill" : template.iconIos + let freq = template.frequency?.displayName ?? "One time" + + let task = OnboardingTaskTemplate( + icon: icon, + title: template.title, + category: catName.lowercased(), + frequency: freq.lowercased(), + color: color + ) + + if grouped[catName] == nil { + grouped[catName] = [] + order.append(catName) + } + grouped[catName]?.append(task) + } + + return order.compactMap { name in + guard let tasks = grouped[name] else { return nil } + let catKey = name.lowercased() + return OnboardingTaskCategory( + name: name, + icon: Self.categoryIcons[catKey] ?? "wrench.fill", + color: Self.categoryColors[catKey] ?? .appPrimary, + tasks: tasks + ) + } + } + + /// Hardcoded fallback categories when no API templates are available + private let fallbackCategories: [OnboardingTaskCategory] = [ OnboardingTaskCategory( name: "HVAC & Climate", icon: "thermometer.medium", @@ -141,6 +218,7 @@ struct OnboardingFirstTaskContent: View { } VStack(spacing: 0) { + ScrollViewReader { proxy in ScrollView(showsIndicators: false) { VStack(spacing: OrganicSpacing.comfortable) { // Header with celebration @@ -208,7 +286,9 @@ struct OnboardingFirstTaskContent: View { .font(.system(size: 26, weight: .bold, design: .rounded)) .foregroundColor(Color.appTextPrimary) - Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!") + Text(onboardingState.regionalTemplates.isEmpty + ? "Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!" + : "Here are tasks recommended for your area.\nPick the ones you'd like to track!") .font(.system(size: 15, weight: .medium)) .foregroundColor(Color.appTextSecondary) .multilineTextAlignment(.center) @@ -237,18 +317,27 @@ struct OnboardingFirstTaskContent: View { OrganicTaskCategorySection( category: category, selectedTasks: $selectedTasks, - isExpanded: expandedCategory == category.name, + isExpanded: expandedCategories.contains(category.name), isAtMaxSelection: isAtMaxSelection, onToggleExpand: { + let isExpanding = !expandedCategories.contains(category.name) withAnimation(.spring(response: 0.3)) { - if expandedCategory == category.name { - expandedCategory = nil + if expandedCategories.contains(category.name) { + expandedCategories.remove(category.name) } else { - expandedCategory = category.name + expandedCategories.insert(category.name) + } + } + if isExpanding { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + withAnimation { + proxy.scrollTo(category.name, anchor: .top) + } } } } ) + .id(category.name) } } .padding(.horizontal, OrganicSpacing.comfortable) @@ -295,6 +384,7 @@ struct OnboardingFirstTaskContent: View { } .padding(.bottom, 140) // Space for button } + } // ScrollViewReader // Bottom action area VStack(spacing: 14) { @@ -341,8 +431,18 @@ struct OnboardingFirstTaskContent: View { } .onAppear { isAnimating = true + // Build and cache categories once to preserve stable UUIDs + if taskCategoriesCache == nil { + if !onboardingState.regionalTemplates.isEmpty { + taskCategoriesCache = categoriesFromAPI(onboardingState.regionalTemplates) + } else { + taskCategoriesCache = fallbackCategories + } + } // Expand first category by default - expandedCategory = taskCategories.first?.name + if let first = taskCategories.first?.name { + expandedCategories.insert(first) + } } .onDisappear { isAnimating = false @@ -350,19 +450,27 @@ struct OnboardingFirstTaskContent: View { } private func selectPopularTasks() { - // Select top popular tasks (up to max allowed) - let popularTaskTitles = [ - "Change HVAC Filter", - "Test Smoke Detectors", - "Check for Leaks", - "Clean Gutters", - "Clean Refrigerator Coils" - ] - withAnimation(.spring(response: 0.3)) { - for task in allTasks where popularTaskTitles.contains(task.title) { - if selectedTasks.count < maxTasksAllowed { - selectedTasks.insert(task.id) + if !onboardingState.regionalTemplates.isEmpty { + // API templates: select the first N tasks (they're ordered by display_order) + for task in allTasks { + if selectedTasks.count < maxTasksAllowed { + selectedTasks.insert(task.id) + } + } + } else { + // Fallback: select hardcoded popular tasks + let popularTaskTitles = [ + "Change HVAC Filter", + "Test Smoke Detectors", + "Check for Leaks", + "Clean Gutters", + "Clean Refrigerator Coils" + ] + for task in allTasks where popularTaskTitles.contains(task.title) { + if selectedTasks.count < maxTasksAllowed { + selectedTasks.insert(task.id) + } } } } @@ -388,6 +496,13 @@ struct OnboardingFirstTaskContent: View { var completedCount = 0 let totalCount = selectedTemplates.count + // Safety: if no templates matched (shouldn't happen), skip + if totalCount == 0 { + isCreatingTasks = false + onTaskAdded() + return + } + // Format today's date as YYYY-MM-DD for the API let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" diff --git a/iosApp/iosApp/Onboarding/OnboardingLocationView.swift b/iosApp/iosApp/Onboarding/OnboardingLocationView.swift new file mode 100644 index 0000000..562e9a3 --- /dev/null +++ b/iosApp/iosApp/Onboarding/OnboardingLocationView.swift @@ -0,0 +1,251 @@ +import SwiftUI + +/// Screen: ZIP code entry for regional task templates - Content only (no navigation bar) +struct OnboardingLocationContent: View { + var onLocationDetected: (String) -> Void + var onSkip: () -> Void + + @State private var zipCode: String = "" + @State private var isAnimating = false + @FocusState private var isTextFieldFocused: Bool + @Environment(\.colorScheme) var colorScheme + + private var isValid: Bool { + zipCode.count == 5 && zipCode.allSatisfy(\.isNumber) + } + + var body: some View { + ZStack { + WarmGradientBackground() + + // Decorative blobs + GeometryReader { geo in + OrganicBlobShape(variation: 1) + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.06), + Color.appPrimary.opacity(0.01), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.3 + ) + ) + .frame(width: geo.size.width * 0.5, height: geo.size.height * 0.25) + .offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.1) + .blur(radius: 20) + + OrganicBlobShape(variation: 2) + .fill( + RadialGradient( + colors: [ + Color.appAccent.opacity(0.05), + Color.appAccent.opacity(0.01), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.25 + ) + ) + .frame(width: geo.size.width * 0.4, height: geo.size.height * 0.2) + .offset(x: geo.size.width * 0.65, y: geo.size.height * 0.75) + .blur(radius: 15) + } + + VStack(spacing: 0) { + Spacer() + + // Content + VStack(spacing: OrganicSpacing.comfortable) { + // Animated location icon + ZStack { + Circle() + .fill( + RadialGradient( + colors: [Color.appPrimary.opacity(0.15), Color.clear], + center: .center, + startRadius: 30, + endRadius: 80 + ) + ) + .frame(width: 160, height: 160) + .offset(x: -20, y: -20) + .scaleEffect(isAnimating ? 1.1 : 1.0) + .animation( + isAnimating + ? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true) + : .default, + value: isAnimating + ) + + Circle() + .fill( + RadialGradient( + colors: [Color.appAccent.opacity(0.15), Color.clear], + center: .center, + startRadius: 30, + endRadius: 80 + ) + ) + .frame(width: 160, height: 160) + .offset(x: 20, y: 20) + .scaleEffect(isAnimating ? 0.95 : 1.05) + .animation( + isAnimating + ? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5) + : .default, + value: isAnimating + ) + + // Main icon + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.appPrimary, Color.appSecondary], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 90, height: 90) + + Image(systemName: "location.fill") + .font(.system(size: 40)) + .foregroundColor(.white) + } + .naturalShadow(.pronounced) + } + + // Title + VStack(spacing: 12) { + Text("Where's your home?") + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + .multilineTextAlignment(.center) + + Text("Enter your ZIP code so we can suggest\nmaintenance tasks for your climate region.") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + + // ZIP code input + VStack(alignment: .center, spacing: 12) { + HStack(spacing: 14) { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.appPrimary.opacity(0.15), Color.appAccent.opacity(0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 40, height: 40) + + Image(systemName: "mappin.circle.fill") + .font(.system(size: 20)) + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary, Color.appAccent], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + + TextField("12345", text: $zipCode) + .font(.system(size: 24, weight: .semibold)) + .keyboardType(.numberPad) + .focused($isTextFieldFocused) + .multilineTextAlignment(.center) + .onChange(of: zipCode) { _, newValue in + // Only allow digits, max 5 + let filtered = String(newValue.filter(\.isNumber).prefix(5)) + if filtered != newValue { + zipCode = filtered + } + } + + if !zipCode.isEmpty { + Button(action: { zipCode = "" }) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 20)) + .foregroundColor(Color.appTextSecondary.opacity(0.5)) + } + } + } + .padding(18) + .frame(maxWidth: 240) + .background( + ZStack { + Color.appBackgroundSecondary + GrainTexture(opacity: 0.01) + } + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke( + isTextFieldFocused + ? LinearGradient(colors: [Color.appPrimary, Color.appAccent], startPoint: .leading, endPoint: .trailing) + : LinearGradient(colors: [Color.appTextSecondary.opacity(0.2), Color.appTextSecondary.opacity(0.2)], startPoint: .leading, endPoint: .trailing), + lineWidth: 2 + ) + ) + .naturalShadow(isTextFieldFocused ? .medium : .subtle) + .animation(.easeInOut(duration: 0.2), value: isTextFieldFocused) + } + .padding(.horizontal, OrganicSpacing.comfortable) + } + + Spacer() + + // Continue button + Button(action: { onLocationDetected(zipCode) }) { + HStack(spacing: 10) { + Text("Continue") + .font(.system(size: 17, weight: .bold)) + + Image(systemName: "arrow.right") + .font(.system(size: 16, weight: .bold)) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + isValid + ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing)) + : AnyShapeStyle(Color.appTextSecondary.opacity(0.4)) + ) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(isValid ? .medium : .subtle) + } + .disabled(!isValid) + .padding(.horizontal, OrganicSpacing.comfortable) + .padding(.bottom, OrganicSpacing.airy) + .animation(.easeInOut(duration: 0.2), value: isValid) + } + } + .onAppear { + isAnimating = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + isTextFieldFocused = true + } + } + .onDisappear { + isAnimating = false + } + } +} + +#Preview { + OnboardingLocationContent( + onLocationDetected: { _ in }, + onSkip: {} + ) +} diff --git a/iosApp/iosApp/Onboarding/OnboardingState.swift b/iosApp/iosApp/Onboarding/OnboardingState.swift index 27fd7ee..7c2e699 100644 --- a/iosApp/iosApp/Onboarding/OnboardingState.swift +++ b/iosApp/iosApp/Onboarding/OnboardingState.swift @@ -36,6 +36,15 @@ class OnboardingState: ObservableObject { /// The ID of the residence created during onboarding (used for task creation) @Published var createdResidenceId: Int32? = nil + /// ZIP code entered during the location step (used for residence creation and regional templates) + @Published var pendingPostalCode: String = "" + + /// Regional task templates loaded from API based on ZIP code + @Published var regionalTemplates: [TaskTemplate] = [] + + /// Whether regional templates are currently loading + @Published var isLoadingTemplates: Bool = false + /// The user's selected intent (start fresh or join existing). /// Reads/writes the persisted @AppStorage value and notifies SwiftUI of the change. var userIntent: OnboardingIntent { @@ -54,6 +63,20 @@ class OnboardingState: ObservableObject { private init() {} + /// Load regional task templates from the backend for the given ZIP code + func loadRegionalTemplates(zip: String) { + pendingPostalCode = zip + isLoadingTemplates = true + Task { + let result = try await APILayer.shared.getRegionalTemplates(state: nil, zip: zip) + if let success = result as? ApiResultSuccess, + let templates = success.data as? [TaskTemplate] { + self.regionalTemplates = templates + } + self.isLoadingTemplates = false + } + } + /// Start the onboarding flow func startOnboarding() { isOnboardingActive = true @@ -62,17 +85,17 @@ class OnboardingState: ObservableObject { } /// Move to the next step in the flow - /// New order: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell + /// Order: Welcome → Features → Name → Account → Verify → Location → Tasks → Upsell func nextStep() { switch currentStep { case .welcome: if userIntent == .joinExisting { currentStep = .createAccount } else { - currentStep = .valueProps // Features first to wow the user + currentStep = .valueProps } case .valueProps: - currentStep = .nameResidence // Then name the house + currentStep = .nameResidence case .nameResidence: currentStep = .createAccount case .createAccount: @@ -81,10 +104,12 @@ class OnboardingState: ObservableObject { if userIntent == .joinExisting { currentStep = .joinResidence } else { - currentStep = .firstTask + currentStep = .residenceLocation } case .joinResidence: currentStep = .subscriptionUpsell + case .residenceLocation: + currentStep = .firstTask case .firstTask: currentStep = .subscriptionUpsell case .subscriptionUpsell: @@ -103,6 +128,8 @@ class OnboardingState: ObservableObject { hasCompletedOnboarding = true isOnboardingActive = false pendingResidenceName = "" + pendingPostalCode = "" + regionalTemplates = [] createdResidenceId = nil userIntent = .unknown } @@ -113,6 +140,8 @@ class OnboardingState: ObservableObject { hasCompletedOnboarding = false isOnboardingActive = false pendingResidenceName = "" + pendingPostalCode = "" + regionalTemplates = [] createdResidenceId = nil userIntent = .unknown currentStep = .welcome @@ -126,9 +155,10 @@ enum OnboardingStep: Int, CaseIterable { 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 + case joinResidence = 5 // Only for users joining with a code + case residenceLocation = 6 // ZIP code entry for regional templates + case firstTask = 7 + case subscriptionUpsell = 8 var title: String { switch self { @@ -144,6 +174,8 @@ enum OnboardingStep: Int, CaseIterable { return "Verify Email" case .joinResidence: return "Join Residence" + case .residenceLocation: + return "Your Location" case .firstTask: return "First Task" case .subscriptionUpsell: diff --git a/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift b/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift index 6dbcb40..e407fc0 100644 --- a/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift @@ -190,7 +190,7 @@ struct OnboardingVerifyEmailContent: View { } // Resend code hint - HStack(spacing: 6) { + HStack(alignment: .top, spacing: 6) { Image(systemName: "info.circle.fill") .font(.system(size: 13)) .foregroundColor(Color.appTextSecondary.opacity(0.7)) @@ -198,8 +198,10 @@ struct OnboardingVerifyEmailContent: View { Text("Didn't receive a code? Check your spam folder or re-register") .font(.system(size: 13, weight: .medium)) .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) } + .padding(.horizontal, 16) .padding(.top, 8) } diff --git a/iosApp/iosApp/RootView.swift b/iosApp/iosApp/RootView.swift index 7b1b9bc..06937cb 100644 --- a/iosApp/iosApp/RootView.swift +++ b/iosApp/iosApp/RootView.swift @@ -157,6 +157,11 @@ struct RootView: View { OnboardingCoordinator(onComplete: { // Onboarding complete - mark verified and refresh the view authManager.markVerified() + // Force refresh all data so tasks created during onboarding appear + Task { + _ = try? await APILayer.shared.getMyResidences(forceRefresh: true) + _ = try? await APILayer.shared.getTasks(forceRefresh: true) + } refreshID = UUID() }) Color.clear diff --git a/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift b/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift index 05ab601..4a07171 100644 --- a/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift +++ b/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift @@ -78,7 +78,15 @@ struct PropertyHeaderCard: View { HStack(spacing: 10) { Color.clear.frame(width: 32, height: 1) // Alignment spacer - Text("\(residence.city), \(residence.stateProvince) \(residence.postalCode)") + Text({ + var parts: [String] = [] + let cityState = [residence.city, residence.stateProvince] + .filter { !$0.isEmpty } + .joined(separator: ", ") + if !cityState.isEmpty { parts.append(cityState) } + if !residence.postalCode.isEmpty { parts.append(residence.postalCode) } + return parts.joined(separator: " ") + }()) .font(.system(size: 14, weight: .medium)) .foregroundColor(Color.appTextSecondary) }