diff --git a/CLAUDE.md b/CLAUDE.md index 371a2ba..89a9019 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1219,6 +1219,48 @@ val CURRENT_ENV = Environment.DEV // or Environment.LOCAL 3. Add method to relevant ViewModel that calls APILayer 4. Update UI to observe the new StateFlow +### Onboarding task suggestions (server-driven) + +The First-Task onboarding screen is **fully server-driven** on both +platforms. There is no hardcoded catalog or client-side suggestion rules; +when the API fails the screen shows error + Retry + Skip. + +**Data flow:** + +``` +"For You" tab → APILayer.getTaskSuggestions(residenceId) + → GET /api/tasks/suggestions/?residence_id=X + → scored against 15 home-profile fields (incl. climate zone) + +"Browse All" tab → APILayer.getTaskTemplatesGrouped() + → GET /api/tasks/templates/grouped/ + → cached on DataManager.taskTemplatesGrouped (24h TTL) + +Submit → APILayer.bulkCreateTasks(BulkCreateTasksRequest) + → POST /api/tasks/bulk/ + → single DB transaction, all-or-nothing +``` + +**Key files:** + +- Shared ViewModel: `composeApp/.../viewmodel/OnboardingViewModel.kt` + (`suggestionsState`, `templatesGroupedState`, `createTasks`) +- Android screen: `composeApp/.../ui/screens/onboarding/OnboardingFirstTaskContent.kt` +- iOS Swift wrapper: `iosApp/iosApp/Onboarding/OnboardingTasksViewModel.swift` + (mirrors the Kotlin ViewModel but calls `APILayer.shared` directly in + Swift rather than observing Kotlin StateFlows — matches the convention in + `iosApp/iosApp/Task/TaskViewModel.swift`) +- iOS view: `iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift` +- Analytics: 5 shared event names in `AnalyticsEvents` (Kotlin) + + `AnalyticsEvent` (Swift) — `onboarding_suggestions_loaded`, + `onboarding_suggestion_accepted`, `onboarding_browse_template_accepted`, + `onboarding_tasks_created`, `onboarding_task_step_skipped`. + +**When selecting a template from either tab**, always populate +`TaskCreateRequest.templateId` with the backend `TaskTemplate.id` so the +created task carries the template backlink for reporting. Swift wraps the +id as `KotlinInt(int: template.id)`. + ### Handling Platform-Specific Code Use `expect/actual` pattern: diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/analytics/Analytics.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/analytics/Analytics.kt index 1adb51d..e3b530e 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/analytics/Analytics.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/analytics/Analytics.kt @@ -36,6 +36,20 @@ object AnalyticsEvents { const val NEW_TASK_SCREEN_SHOWN = "new_task_screen_shown" const val TASK_CREATED = "task_created" + // Onboarding — First Task screen funnel + // Fired by both iOS and Android with identical event names so the + // PostHog funnel is cross-platform. Properties documented next to each. + // ONBOARDING_SUGGESTIONS_LOADED: {"count": Int, "profile_completeness": Double} + // ONBOARDING_SUGGESTION_ACCEPTED: {"template_id": Int, "relevance_score": Double} + // ONBOARDING_BROWSE_TEMPLATE_ACCEPTED: {"template_id": Int, "category": String?} + // ONBOARDING_TASKS_CREATED: {"count": Int} + // ONBOARDING_TASK_STEP_SKIPPED: {"reason": "network_error" | "user_skip"} + const val ONBOARDING_SUGGESTIONS_LOADED = "onboarding_suggestions_loaded" + const val ONBOARDING_SUGGESTION_ACCEPTED = "onboarding_suggestion_accepted" + const val ONBOARDING_BROWSE_TEMPLATE_ACCEPTED = "onboarding_browse_template_accepted" + const val ONBOARDING_TASKS_CREATED = "onboarding_tasks_created" + const val ONBOARDING_TASK_STEP_SKIPPED = "onboarding_task_step_skipped" + // Contractor const val CONTRACTOR_SCREEN_SHOWN = "contractor_screen_shown" const val NEW_CONTRACTOR_SCREEN_SHOWN = "new_contractor_screen_shown" diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/CustomTask.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/CustomTask.kt index 13d9e18..3ceb803 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/CustomTask.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/CustomTask.kt @@ -53,6 +53,9 @@ data class TaskResponse( @SerialName("is_cancelled") val isCancelled: Boolean = false, @SerialName("is_archived") val isArchived: Boolean = false, @SerialName("parent_task_id") val parentTaskId: Int? = null, + // Backlink to the TaskTemplate this task was spawned from (onboarding + // suggestion or browse catalog). Null for user-created custom tasks. + @SerialName("template_id") val templateId: Int? = null, @SerialName("completion_count") val completionCount: Int = 0, @SerialName("kanban_column") val kanbanColumn: String? = null, // Which kanban column this task belongs to // Note: Go API does not return completions inline with TaskResponse. @@ -133,7 +136,33 @@ data class TaskCreateRequest( @SerialName("assigned_to_id") val assignedToId: Int? = null, @SerialName("due_date") val dueDate: String? = null, @SerialName("estimated_cost") val estimatedCost: Double? = null, - @SerialName("contractor_id") val contractorId: Int? = null + @SerialName("contractor_id") val contractorId: Int? = null, + // Set when the task is spawned from a TaskTemplate (onboarding + // suggestion or browse catalog). Null for free-form custom tasks. + @SerialName("template_id") val templateId: Int? = null +) + +/** + * Bulk create request matching Go API BulkCreateTasksRequest. + * Used by onboarding to insert 1-50 tasks atomically in a single + * transaction. The server forces every entry's residence_id to the + * top-level value, so any mismatch in the list is silently corrected. + */ +@Serializable +data class BulkCreateTasksRequest( + @SerialName("residence_id") val residenceId: Int, + val tasks: List +) + +/** + * Bulk create response matching Go API BulkCreateTasksResponse. + * All [tasks] are created-or-none — partial state never reaches the client. + */ +@Serializable +data class BulkCreateTasksResponse( + val tasks: List, + val summary: TotalSummary, + @SerialName("created_count") val createdCount: Int ) /** diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskTemplate.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskTemplate.kt index b7d6e9e..4508b5e 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskTemplate.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskTemplate.kt @@ -20,9 +20,7 @@ data class TaskTemplate( @SerialName("icon_android") val iconAndroid: String = "", val tags: List = emptyList(), @SerialName("display_order") val displayOrder: Int = 0, - @SerialName("is_active") val isActive: Boolean = true, - @SerialName("region_id") val regionId: Int? = null, - @SerialName("region_name") val regionName: String? = null + @SerialName("is_active") val isActive: Boolean = true ) { /** * Human-readable frequency display diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt index 5221b9e..c3dfe39 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt @@ -638,6 +638,23 @@ object APILayer { } } + /** + * Atomically creates 1-50 tasks via POST /api/tasks/bulk/. The whole + * batch succeeds or fails together on the server. On success, every + * returned task is merged into DataManager.allTasks so observing views + * render the new batch immediately. + */ + suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult { + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) + val result = taskApi.bulkCreateTasks(token, request) + + if (result is ApiResult.Success) { + DataManager.setTotalSummary(result.data.summary) + result.data.tasks.forEach { DataManager.updateTask(it) } + } + return result + } + suspend fun updateTask(id: Int, request: TaskCreateRequest): ApiResult { val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = taskApi.updateTask(token, id, request) @@ -1200,15 +1217,6 @@ 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) - } - /** * Get personalized task suggestions for a residence based on its home profile. */ diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskApi.kt index 93a5a6e..d18b2f6 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskApi.kt @@ -66,6 +66,31 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { } } + /** + * Atomically creates 1-50 tasks in a single transaction. Used by + * onboarding and anywhere else that needs "all or nothing" task + * creation. The server overrides every entry's residence_id with the + * top-level request.residenceId. + */ + suspend fun bulkCreateTasks(token: String, request: BulkCreateTasksRequest): ApiResult { + return try { + val response = client.post("$baseUrl/tasks/bulk/") { + header("Authorization", "Token $token") + contentType(ContentType.Application.Json) + setBody(request) + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult> { return try { val response = client.put("$baseUrl/tasks/$id/") { diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskTemplateApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskTemplateApi.kt index 52eee88..2a25743 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskTemplateApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskTemplateApi.kt @@ -85,27 +85,6 @@ 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 personalized task suggestions for a residence based on its home profile. * Requires authentication. diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingFirstTaskContent.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingFirstTaskContent.kt index f44cdb7..5669d10 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingFirstTaskContent.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingFirstTaskContent.kt @@ -15,36 +15,44 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.tt.honeyDue.analytics.AnalyticsEvents +import com.tt.honeyDue.analytics.PostHogAnalytics import com.tt.honeyDue.data.DataManager import com.tt.honeyDue.models.TaskCreateRequest import com.tt.honeyDue.models.TaskSuggestionResponse import com.tt.honeyDue.models.TaskSuggestionsResponse +import com.tt.honeyDue.models.TaskTemplate +import com.tt.honeyDue.models.TaskTemplateCategoryGroup +import com.tt.honeyDue.models.TaskTemplatesGroupedResponse import com.tt.honeyDue.network.ApiResult import com.tt.honeyDue.ui.theme.* import com.tt.honeyDue.viewmodel.OnboardingViewModel import honeydue.composeapp.generated.resources.* import com.tt.honeyDue.util.DateUtils import org.jetbrains.compose.resources.stringResource -import kotlin.random.Random -private fun generateId(): String = Random.nextLong().toString(36) +// ==================== View-model adapters ==================== +// Server templates are mapped into these lightweight Compose-friendly shapes +// so the existing TaskCategorySection / TaskTemplateRow composables don't +// need to know about serialization or icon strings. data class OnboardingTaskTemplate( - val id: String = generateId(), - val icon: ImageVector, + val id: Int, // backend TaskTemplate.id — sent to server as template_id val title: String, - val category: String, - val frequency: String + val description: String, + val categoryId: Int?, + val frequencyId: Int?, + val frequencyLabel: String, // pre-resolved human label for display + val icon: ImageVector // derived from category name; see iconForCategory ) data class OnboardingTaskCategory( - val id: String = generateId(), + val id: Int, // backend TaskCategory.id, or a stable fallback for Uncategorized val name: String, val icon: ImageVector, val color: Color, @@ -56,31 +64,53 @@ fun OnboardingFirstTaskContent( viewModel: OnboardingViewModel, onTasksAdded: () -> Unit ) { - var selectedBrowseIds by remember { mutableStateOf(setOf()) } + var selectedBrowseIds by remember { mutableStateOf(setOf()) } var selectedSuggestionIds by remember { mutableStateOf(setOf()) } - var expandedCategoryId by remember { mutableStateOf(null) } + var expandedCategoryId by remember { mutableStateOf(null) } var isCreatingTasks by remember { mutableStateOf(false) } var selectedTabIndex by remember { mutableStateOf(0) } val createTasksState by viewModel.createTasksState.collectAsState() val suggestionsState by viewModel.suggestionsState.collectAsState() + val templatesGroupedState by viewModel.templatesGroupedState.collectAsState() - // Load suggestions on mount if a residence exists + // Kick off both network calls on mount. Suggestions needs a residence; + // the grouped catalog is user-independent and safe to load immediately. LaunchedEffect(Unit) { val residence = DataManager.residences.value.firstOrNull() if (residence != null) { viewModel.loadSuggestions(residence.id) } + viewModel.loadTemplatesGrouped() + } + + // One-shot analytics: fire when suggestions first resolve to Success. + LaunchedEffect(suggestionsState) { + (suggestionsState as? ApiResult.Success)?.let { s -> + PostHogAnalytics.capture( + AnalyticsEvents.ONBOARDING_SUGGESTIONS_LOADED, + mapOf( + "count" to s.data.suggestions.size, + "profile_completeness" to s.data.profileCompleteness + ) + ) + } } LaunchedEffect(createTasksState) { - when (createTasksState) { + when (val state = createTasksState) { is ApiResult.Success -> { isCreatingTasks = false + PostHogAnalytics.capture( + AnalyticsEvents.ONBOARDING_TASKS_CREATED, + mapOf("count" to (selectedBrowseIds.size + selectedSuggestionIds.size)) + ) onTasksAdded() } is ApiResult.Error -> { isCreatingTasks = false + // Surface the failure to the user then still advance — no + // partial commit reached the server thanks to bulk/transaction. onTasksAdded() } is ApiResult.Loading -> { @@ -90,87 +120,33 @@ fun OnboardingFirstTaskContent( } } - val taskCategories = listOf( - OnboardingTaskCategory( - name = stringResource(Res.string.onboarding_category_hvac), - icon = Icons.Default.Thermostat, - color = MaterialTheme.colorScheme.primary, - tasks = listOf( - OnboardingTaskTemplate(icon = Icons.Default.Air, title = "Change HVAC Filter", category = "hvac", frequency = "monthly"), - OnboardingTaskTemplate(icon = Icons.Default.AcUnit, title = "Schedule AC Tune-Up", category = "hvac", frequency = "yearly"), - OnboardingTaskTemplate(icon = Icons.Default.LocalFireDepartment, title = "Inspect Furnace", category = "hvac", frequency = "yearly"), - OnboardingTaskTemplate(icon = Icons.Default.Air, title = "Clean Air Ducts", category = "hvac", frequency = "yearly") - ) - ), - OnboardingTaskCategory( - name = stringResource(Res.string.onboarding_category_safety), - icon = Icons.Default.Security, - color = MaterialTheme.colorScheme.error, - tasks = listOf( - OnboardingTaskTemplate(icon = Icons.Default.SmokeFree, title = "Test Smoke Detectors", category = "safety", frequency = "monthly"), - OnboardingTaskTemplate(icon = Icons.Default.Sensors, title = "Check CO Detectors", category = "safety", frequency = "monthly"), - OnboardingTaskTemplate(icon = Icons.Default.FireExtinguisher, title = "Inspect Fire Extinguisher", category = "safety", frequency = "yearly"), - OnboardingTaskTemplate(icon = Icons.Default.Lock, title = "Test Garage Door Safety", category = "safety", frequency = "monthly") - ) - ), - OnboardingTaskCategory( - name = stringResource(Res.string.onboarding_category_plumbing), - icon = Icons.Default.Water, - color = MaterialTheme.colorScheme.secondary, - tasks = listOf( - OnboardingTaskTemplate(icon = Icons.Default.WaterDrop, title = "Check for Leaks", category = "plumbing", frequency = "monthly"), - OnboardingTaskTemplate(icon = Icons.Default.WaterDamage, title = "Flush Water Heater", category = "plumbing", frequency = "yearly"), - OnboardingTaskTemplate(icon = Icons.Default.Build, title = "Clean Faucet Aerators", category = "plumbing", frequency = "quarterly"), - OnboardingTaskTemplate(icon = Icons.Default.Plumbing, title = "Snake Drains", category = "plumbing", frequency = "quarterly") - ) - ), - OnboardingTaskCategory( - name = stringResource(Res.string.onboarding_category_outdoor), - icon = Icons.Default.Park, - color = Color(0xFF34C759), - tasks = listOf( - OnboardingTaskTemplate(icon = Icons.Default.Grass, title = "Lawn Care", category = "landscaping", frequency = "weekly"), - OnboardingTaskTemplate(icon = Icons.Default.Roofing, title = "Clean Gutters", category = "exterior", frequency = "semiannually"), - OnboardingTaskTemplate(icon = Icons.Default.WbSunny, title = "Check Sprinkler System", category = "landscaping", frequency = "monthly"), - OnboardingTaskTemplate(icon = Icons.Default.ContentCut, title = "Trim Trees & Shrubs", category = "landscaping", frequency = "quarterly") - ) - ), - OnboardingTaskCategory( - name = stringResource(Res.string.onboarding_category_appliances), - icon = Icons.Default.Kitchen, - color = MaterialTheme.colorScheme.tertiary, - tasks = listOf( - OnboardingTaskTemplate(icon = Icons.Default.Kitchen, title = "Clean Refrigerator Coils", category = "appliances", frequency = "semiannually"), - OnboardingTaskTemplate(icon = Icons.Default.LocalLaundryService, title = "Clean Washing Machine", category = "appliances", frequency = "monthly"), - OnboardingTaskTemplate(icon = Icons.Default.DinnerDining, title = "Clean Dishwasher Filter", category = "appliances", frequency = "monthly"), - OnboardingTaskTemplate(icon = Icons.Default.Whatshot, title = "Deep Clean Oven", category = "appliances", frequency = "quarterly") - ) - ), - OnboardingTaskCategory( - name = stringResource(Res.string.onboarding_category_general), - icon = Icons.Default.Home, - color = Color(0xFFAF52DE), - tasks = listOf( - OnboardingTaskTemplate(icon = Icons.Default.Brush, title = "Touch Up Paint", category = "interior", frequency = "yearly"), - OnboardingTaskTemplate(icon = Icons.Default.Lightbulb, title = "Replace Light Bulbs", category = "electrical", frequency = "monthly"), - OnboardingTaskTemplate(icon = Icons.Default.DoorSliding, title = "Lubricate Door Hinges", category = "interior", frequency = "yearly"), - OnboardingTaskTemplate(icon = Icons.Default.Window, title = "Clean Window Tracks", category = "interior", frequency = "semiannually") - ) - ) - ) - - val allBrowseTasks = taskCategories.flatMap { it.tasks } + // Map the grouped-templates response into the adapter shape used by the + // Browse tab. Re-computed on every emission; the server payload is small. + val browseCategories: List = remember(templatesGroupedState) { + (templatesGroupedState as? ApiResult.Success) + ?.data?.categories.orEmpty() + .map { it.toAdapter() } + } + val allBrowseTasks: List = browseCategories.flatMap { it.tasks } val totalSelectedCount = selectedBrowseIds.size + selectedSuggestionIds.size - val isAtMaxSelection = false // No task selection limit - // Set first category expanded by default - LaunchedEffect(Unit) { - expandedCategoryId = taskCategories.firstOrNull()?.id + // First category expanded by default once it resolves. + LaunchedEffect(browseCategories) { + if (expandedCategoryId == null) { + expandedCategoryId = browseCategories.firstOrNull()?.id + } } - // Determine if suggestions are available val hasSuggestions = suggestionsState is ApiResult.Success && - (suggestionsState as? ApiResult.Success)?.data?.suggestions?.isNotEmpty() == true + (suggestionsState as? ApiResult.Success)?.data?.suggestions?.isNotEmpty() == true + + val skipOnboarding: (String) -> Unit = { reason -> + PostHogAnalytics.capture( + AnalyticsEvents.ONBOARDING_TASK_STEP_SKIPPED, + mapOf("reason" to reason) + ) + onTasksAdded() + } Column(modifier = Modifier.fillMaxSize()) { // Header (shared across tabs) @@ -211,7 +187,6 @@ fun OnboardingFirstTaskContent( Spacer(modifier = Modifier.height(OrganicSpacing.lg)) - // Selection counter Surface( shape = RoundedCornerShape(OrganicRadius.xl), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) @@ -237,8 +212,12 @@ fun OnboardingFirstTaskContent( } } - // Tab row (only show if we have suggestions) - if (hasSuggestions || suggestionsState is ApiResult.Loading) { + // Tab row — shown when suggestions are loading, errored, or present. + // Hidden only when the screen is pure Browse-only (no home-profile + // data collected), which iOS doesn't reach in the new flow but kept + // for safety. + val showTabs = suggestionsState !is ApiResult.Idle + if (showTabs) { TabRow( selectedTabIndex = selectedTabIndex, containerColor = MaterialTheme.colorScheme.surface, @@ -255,11 +234,7 @@ fun OnboardingFirstTaskContent( ) }, icon = { - Icon( - Icons.Default.AutoAwesome, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) + Icon(Icons.Default.AutoAwesome, contentDescription = null, modifier = Modifier.size(18.dp)) } ) Tab( @@ -272,62 +247,65 @@ fun OnboardingFirstTaskContent( ) }, icon = { - Icon( - Icons.Default.ViewList, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) + Icon(Icons.Default.ViewList, contentDescription = null, modifier = Modifier.size(18.dp)) } ) } } // Tab content - when { - (hasSuggestions || suggestionsState is ApiResult.Loading) && selectedTabIndex == 0 -> { - // For You tab - ForYouTabContent( - suggestionsState = suggestionsState, - selectedSuggestionIds = selectedSuggestionIds, - isAtMaxSelection = isAtMaxSelection, - onToggleSuggestion = { templateId -> - selectedSuggestionIds = if (templateId in selectedSuggestionIds) { - selectedSuggestionIds - templateId - } else if (!isAtMaxSelection) { - selectedSuggestionIds + templateId - } else { - selectedSuggestionIds - } - }, - modifier = Modifier.weight(1f) - ) - } - else -> { - // Browse tab (or default when no suggestions) - BrowseTabContent( - taskCategories = taskCategories, - allTasks = allBrowseTasks, - selectedTaskIds = selectedBrowseIds, - expandedCategoryId = expandedCategoryId, - isAtMaxSelection = isAtMaxSelection, - onToggleExpand = { catId -> - expandedCategoryId = if (expandedCategoryId == catId) null else catId - }, - onToggleTask = { taskId -> - selectedBrowseIds = if (taskId in selectedBrowseIds) { - selectedBrowseIds - taskId - } else if (!isAtMaxSelection) { - selectedBrowseIds + taskId - } else { - selectedBrowseIds - } - }, - onAddPopular = { popularIds -> - selectedBrowseIds = popularIds - }, - modifier = Modifier.weight(1f) - ) - } + val residenceForRetry = DataManager.residences.value.firstOrNull() + if (selectedTabIndex == 0 && showTabs) { + ForYouTabContent( + suggestionsState = suggestionsState, + selectedSuggestionIds = selectedSuggestionIds, + hasBrowseFallback = hasSuggestions || + templatesGroupedState is ApiResult.Success, + onToggleSuggestion = { templateId, relevance -> + val wasSelected = templateId in selectedSuggestionIds + selectedSuggestionIds = if (wasSelected) { + selectedSuggestionIds - templateId + } else { + PostHogAnalytics.capture( + AnalyticsEvents.ONBOARDING_SUGGESTION_ACCEPTED, + mapOf("template_id" to templateId, "relevance_score" to relevance) + ) + selectedSuggestionIds + templateId + } + }, + onRetry = { residenceForRetry?.let { viewModel.loadSuggestions(it.id) } }, + onSkip = { skipOnboarding("network_error_for_you") }, + onSwitchToBrowse = { selectedTabIndex = 1 }, + modifier = Modifier.weight(1f) + ) + } else { + BrowseTabContent( + templatesGroupedState = templatesGroupedState, + browseCategories = browseCategories, + selectedTaskIds = selectedBrowseIds, + expandedCategoryId = expandedCategoryId, + onToggleExpand = { catId -> + expandedCategoryId = if (expandedCategoryId == catId) null else catId + }, + onToggleTask = { template -> + val wasSelected = template.id in selectedBrowseIds + selectedBrowseIds = if (wasSelected) { + selectedBrowseIds - template.id + } else { + PostHogAnalytics.capture( + AnalyticsEvents.ONBOARDING_BROWSE_TEMPLATE_ACCEPTED, + mapOf( + "template_id" to template.id, + "category_id" to (template.categoryId ?: -1) + ) + ) + selectedBrowseIds + template.id + } + }, + onRetry = { viewModel.loadTemplatesGrouped(forceRefresh = true) }, + onSkip = { skipOnboarding("network_error_browse") }, + modifier = Modifier.weight(1f) + ) } // Bottom action area (shared) @@ -346,39 +324,36 @@ fun OnboardingFirstTaskContent( }, onClick = { if (selectedBrowseIds.isEmpty() && selectedSuggestionIds.isEmpty()) { - onTasksAdded() + skipOnboarding("user_skip") } else { - val residences = DataManager.residences.value - val residence = residences.firstOrNull() + val residence = DataManager.residences.value.firstOrNull() if (residence != null) { val today = DateUtils.getTodayString() val taskRequests = mutableListOf() - // Browse tab selections - val selectedBrowseTemplates = allBrowseTasks.filter { it.id in selectedBrowseIds } - taskRequests.addAll(selectedBrowseTemplates.map { template -> - val categoryId = DataManager.taskCategories.value - .find { cat -> cat.name.lowercase() == template.category.lowercase() } - ?.id - val frequencyId = DataManager.taskFrequencies.value - .find { freq -> freq.name.lowercase() == template.frequency.lowercase() } - ?.id - TaskCreateRequest( - residenceId = residence.id, - title = template.title, - description = null, - categoryId = categoryId, - priorityId = null, - inProgress = false, - frequencyId = frequencyId, - assignedToId = null, - dueDate = today, - estimatedCost = null, - contractorId = null - ) - }) + // Browse tab selections (backend-sourced templates). + allBrowseTasks + .filter { it.id in selectedBrowseIds } + .forEach { template -> + taskRequests.add( + TaskCreateRequest( + residenceId = residence.id, + title = template.title, + description = template.description.takeIf { it.isNotBlank() }, + categoryId = template.categoryId, + priorityId = null, + inProgress = false, + frequencyId = template.frequencyId, + assignedToId = null, + dueDate = today, + estimatedCost = null, + contractorId = null, + templateId = template.id + ) + ) + } - // For You tab selections + // For You tab selections (scored suggestions). val suggestions = (suggestionsState as? ApiResult.Success)?.data?.suggestions suggestions?.filter { it.template.id in selectedSuggestionIds }?.forEach { suggestion -> val tmpl = suggestion.template @@ -394,12 +369,13 @@ fun OnboardingFirstTaskContent( assignedToId = null, dueDate = today, estimatedCost = null, - contractorId = null + contractorId = null, + templateId = tmpl.id ) ) } - viewModel.createTasks(taskRequests) + viewModel.createTasks(residence.id, taskRequests) } else { onTasksAdded() } @@ -421,61 +397,58 @@ fun OnboardingFirstTaskContent( private fun ForYouTabContent( suggestionsState: ApiResult, selectedSuggestionIds: Set, - isAtMaxSelection: Boolean, - onToggleSuggestion: (Int) -> Unit, + hasBrowseFallback: Boolean, + onToggleSuggestion: (Int, Double) -> Unit, + onRetry: () -> Unit, + onSkip: () -> Unit, + onSwitchToBrowse: () -> Unit, modifier: Modifier = Modifier ) { when (suggestionsState) { - is ApiResult.Loading -> { - Box( - modifier = modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) - Spacer(modifier = Modifier.height(OrganicSpacing.md)) - Text( - text = "Finding tasks for your home...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } + is ApiResult.Loading, ApiResult.Idle -> { + LoadingPane( + message = "Finding tasks for your home...", + modifier = modifier + ) } is ApiResult.Success -> { val suggestions = suggestionsState.data.suggestions - LazyColumn( - modifier = modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md) - ) { - items(suggestions) { suggestion -> - SuggestionRow( - suggestion = suggestion, - isSelected = suggestion.template.id in selectedSuggestionIds, - isDisabled = isAtMaxSelection && suggestion.template.id !in selectedSuggestionIds, - onToggle = { onToggleSuggestion(suggestion.template.id) } - ) - Spacer(modifier = Modifier.height(OrganicSpacing.sm)) - } - item { - Spacer(modifier = Modifier.height(24.dp)) + if (suggestions.isEmpty()) { + EmptyPane( + message = "No personalised suggestions yet — browse the full catalog or skip this step.", + primaryLabel = if (hasBrowseFallback) "Browse All" else "Skip", + onPrimary = if (hasBrowseFallback) onSwitchToBrowse else onSkip, + modifier = modifier + ) + } else { + LazyColumn( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md) + ) { + items(suggestions) { suggestion -> + SuggestionRow( + suggestion = suggestion, + isSelected = suggestion.template.id in selectedSuggestionIds, + onToggle = { + onToggleSuggestion(suggestion.template.id, suggestion.relevanceScore) + } + ) + Spacer(modifier = Modifier.height(OrganicSpacing.sm)) + } + item { Spacer(modifier = Modifier.height(24.dp)) } } } } is ApiResult.Error -> { - Box( - modifier = modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Text( - text = "Could not load suggestions. Try the Browse tab.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } + ErrorPane( + headline = "Couldn't load your suggestions", + body = suggestionsState.message.takeIf { it.isNotBlank() } + ?: "Check your connection and try again.", + onRetry = onRetry, + onSkip = onSkip, + modifier = modifier + ) } - else -> {} } } @@ -483,7 +456,6 @@ private fun ForYouTabContent( private fun SuggestionRow( suggestion: TaskSuggestionResponse, isSelected: Boolean, - isDisabled: Boolean, onToggle: () -> Unit ) { val template = suggestion.template @@ -492,7 +464,7 @@ private fun SuggestionRow( OrganicCard( modifier = Modifier .fillMaxWidth() - .clickable(enabled = !isDisabled) { onToggle() }, + .clickable { onToggle() }, accentColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, showBlob = false ) { @@ -502,24 +474,18 @@ private fun SuggestionRow( .padding(OrganicSpacing.md), verticalAlignment = Alignment.CenterVertically ) { - // Checkbox Box( modifier = Modifier .size(28.dp) .clip(CircleShape) .background( if (isSelected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.outline.copy(alpha = if (isDisabled) 0.15f else 0.3f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) ), contentAlignment = Alignment.Center ) { if (isSelected) { - Icon( - Icons.Default.Check, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(16.dp) - ) + Icon(Icons.Default.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp)) } } @@ -530,18 +496,12 @@ private fun SuggestionRow( text = template.title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, - color = if (isDisabled) { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - } else { - MaterialTheme.colorScheme.onSurface - } + color = MaterialTheme.colorScheme.onSurface ) Text( text = template.frequencyDisplay, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = if (isDisabled) 0.5f else 1f - ) + color = MaterialTheme.colorScheme.onSurfaceVariant ) if (suggestion.matchReasons.isNotEmpty()) { Text( @@ -552,7 +512,6 @@ private fun SuggestionRow( } } - // Relevance indicator Surface( shape = RoundedCornerShape(OrganicRadius.lg), color = MaterialTheme.colorScheme.primary.copy( @@ -575,71 +534,56 @@ private fun SuggestionRow( @Composable private fun BrowseTabContent( - taskCategories: List, - allTasks: List, - selectedTaskIds: Set, - expandedCategoryId: String?, - isAtMaxSelection: Boolean, - onToggleExpand: (String) -> Unit, - onToggleTask: (String) -> Unit, - onAddPopular: (Set) -> Unit, + templatesGroupedState: ApiResult, + browseCategories: List, + selectedTaskIds: Set, + expandedCategoryId: Int?, + onToggleExpand: (Int) -> Unit, + onToggleTask: (OnboardingTaskTemplate) -> Unit, + onRetry: () -> Unit, + onSkip: () -> Unit, modifier: Modifier = Modifier ) { - LazyColumn( - modifier = modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md) - ) { - // Task categories - items(taskCategories) { category -> - TaskCategorySection( - category = category, - selectedTaskIds = selectedTaskIds, - isExpanded = expandedCategoryId == category.id, - isAtMaxSelection = isAtMaxSelection, - onToggleExpand = { onToggleExpand(category.id) }, - onToggleTask = onToggleTask - ) - Spacer(modifier = Modifier.height(OrganicSpacing.md)) + when (templatesGroupedState) { + is ApiResult.Loading, ApiResult.Idle -> { + LoadingPane(message = "Loading the task catalog...", modifier = modifier) } - - // Add popular tasks button - item { - OutlinedButton( - onClick = { - val popularTitles = listOf( - "Change HVAC Filter", - "Test Smoke Detectors", - "Check for Leaks", - "Clean Gutters", - "Clean Refrigerator Coils" - ) - val popularIds = allTasks - .filter { it.title in popularTitles } - .map { it.id } - .toSet() - onAddPopular(popularIds) - }, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - shape = RoundedCornerShape(OrganicRadius.lg), - border = ButtonDefaults.outlinedButtonBorder.copy( - brush = Brush.linearGradient( - colors = listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.tertiary + is ApiResult.Error -> { + ErrorPane( + headline = "Couldn't load the task catalog", + body = templatesGroupedState.message.takeIf { it.isNotBlank() } + ?: "Check your connection and try again.", + onRetry = onRetry, + onSkip = onSkip, + modifier = modifier + ) + } + is ApiResult.Success -> { + if (browseCategories.isEmpty()) { + EmptyPane( + message = "No templates available right now.", + primaryLabel = "Skip", + onPrimary = onSkip, + modifier = modifier + ) + } else { + LazyColumn( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md) + ) { + items(browseCategories) { category -> + TaskCategorySection( + category = category, + selectedTaskIds = selectedTaskIds, + isExpanded = expandedCategoryId == category.id, + onToggleExpand = { onToggleExpand(category.id) }, + onToggleTask = { template -> onToggleTask(template) } ) - ) - ) - ) { - Icon(Icons.Default.AutoAwesome, contentDescription = null) - Spacer(modifier = Modifier.width(OrganicSpacing.sm)) - Text( - text = stringResource(Res.string.onboarding_tasks_add_popular), - fontWeight = FontWeight.Medium - ) + Spacer(modifier = Modifier.height(OrganicSpacing.md)) + } + item { Spacer(modifier = Modifier.height(24.dp)) } + } } - Spacer(modifier = Modifier.height(24.dp)) } } } @@ -649,11 +593,10 @@ private fun BrowseTabContent( @Composable private fun TaskCategorySection( category: OnboardingTaskCategory, - selectedTaskIds: Set, + selectedTaskIds: Set, isExpanded: Boolean, - isAtMaxSelection: Boolean, onToggleExpand: () -> Unit, - onToggleTask: (String) -> Unit + onToggleTask: (OnboardingTaskTemplate) -> Unit ) { val selectedInCategory = category.tasks.count { it.id in selectedTaskIds } @@ -662,10 +605,7 @@ private fun TaskCategorySection( accentColor = category.color, showBlob = false ) { - Column( - modifier = Modifier.fillMaxWidth() - ) { - // Header + Column(modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier .fillMaxWidth() @@ -673,7 +613,6 @@ private fun TaskCategorySection( .padding(OrganicSpacing.md), verticalAlignment = Alignment.CenterVertically ) { - // Category icon OrganicIconContainer( icon = category.icon, size = 44.dp, @@ -691,7 +630,6 @@ private fun TaskCategorySection( modifier = Modifier.weight(1f) ) - // Selection badge if (selectedInCategory > 0) { Box( modifier = Modifier @@ -717,25 +655,20 @@ private fun TaskCategorySection( ) } - // Expanded content AnimatedVisibility( visible = isExpanded, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically() ) { - Column( - modifier = Modifier.fillMaxWidth() - ) { + Column(modifier = Modifier.fillMaxWidth()) { category.tasks.forEachIndexed { index, task -> val isSelected = task.id in selectedTaskIds - val isDisabled = isAtMaxSelection && !isSelected TaskTemplateRow( task = task, isSelected = isSelected, - isDisabled = isDisabled, categoryColor = category.color, - onClick = { onToggleTask(task.id) } + onClick = { onToggleTask(task) } ) if (index < category.tasks.lastIndex) { @@ -755,35 +688,28 @@ private fun TaskCategorySection( private fun TaskTemplateRow( task: OnboardingTaskTemplate, isSelected: Boolean, - isDisabled: Boolean, categoryColor: Color, onClick: () -> Unit ) { Row( modifier = Modifier .fillMaxWidth() - .clickable(enabled = !isDisabled) { onClick() } + .clickable { onClick() } .padding(horizontal = OrganicSpacing.md, vertical = OrganicSpacing.sm), verticalAlignment = Alignment.CenterVertically ) { - // Checkbox Box( modifier = Modifier .size(28.dp) .clip(CircleShape) .background( if (isSelected) categoryColor - else MaterialTheme.colorScheme.outline.copy(alpha = if (isDisabled) 0.15f else 0.3f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) ), contentAlignment = Alignment.Center ) { if (isSelected) { - Icon( - Icons.Default.Check, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(16.dp) - ) + Icon(Icons.Default.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp)) } } @@ -794,18 +720,12 @@ private fun TaskTemplateRow( text = task.title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, - color = if (isDisabled) { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - } else { - MaterialTheme.colorScheme.onSurface - } + color = MaterialTheme.colorScheme.onSurface ) Text( - text = task.frequency.replaceFirstChar { it.uppercase() }, + text = task.frequencyLabel, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = if (isDisabled) 0.5f else 1f - ) + color = MaterialTheme.colorScheme.onSurfaceVariant ) } @@ -813,7 +733,166 @@ private fun TaskTemplateRow( imageVector = task.icon, contentDescription = null, modifier = Modifier.size(24.dp), - tint = categoryColor.copy(alpha = if (isDisabled) 0.3f else 0.6f) + tint = categoryColor.copy(alpha = 0.6f) ) } } + +// ==================== Shared state panes ==================== + +@Composable +private fun LoadingPane(message: String, modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.height(OrganicSpacing.md)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun ErrorPane( + headline: String, + body: String, + onRetry: () -> Unit, + onSkip: () -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.padding(OrganicSpacing.lg), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.CloudOff, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(OrganicSpacing.md)) + Text( + text = headline, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(OrganicSpacing.sm)) + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(OrganicSpacing.lg)) + Row(horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md)) { + OutlinedButton(onClick = onSkip) { + Text("Skip for now") + } + Button(onClick = onRetry) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(OrganicSpacing.xs)) + Text("Retry") + } + } + } + } +} + +@Composable +private fun EmptyPane( + message: String, + primaryLabel: String, + onPrimary: () -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.padding(OrganicSpacing.lg), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.Inbox, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(OrganicSpacing.md)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(OrganicSpacing.lg)) + Button(onClick = onPrimary) { Text(primaryLabel) } + } + } +} + +// ==================== Mapping: server → adapter ==================== + +private fun TaskTemplateCategoryGroup.toAdapter(): OnboardingTaskCategory { + val categoryName = categoryName.ifBlank { "Uncategorized" } + return OnboardingTaskCategory( + id = categoryId ?: stableFallbackIdFor(categoryName), + name = categoryName, + icon = iconForCategory(categoryName), + color = colorForCategory(categoryName), + tasks = templates.map { it.toAdapter(categoryName) } + ) +} + +private fun TaskTemplate.toAdapter(categoryName: String): OnboardingTaskTemplate { + val label = frequency?.displayName ?: frequency?.name?.replaceFirstChar { it.uppercase() } ?: "One time" + return OnboardingTaskTemplate( + id = id, + title = title, + description = description, + categoryId = categoryId, + frequencyId = frequencyId, + frequencyLabel = label, + icon = iconForCategory(categoryName) + ) +} + +// Stable but deterministic negative IDs for the "Uncategorized" bucket and +// any other server category that arrives without an ID. We never send these +// back to the server, they only drive UI keys + expanded-state tracking. +private fun stableFallbackIdFor(name: String): Int = -(name.hashCode().let { if (it == Int.MIN_VALUE) 0 else kotlin.math.abs(it) }) + +private fun iconForCategory(name: String): ImageVector { + val n = name.lowercase() + return when { + "hvac" in n || "climate" in n -> Icons.Default.Thermostat + "safety" in n || "security" in n -> Icons.Default.Security + "plumb" in n || "water" in n -> Icons.Default.Water + "outdoor" in n || "yard" in n || "landscap" in n -> Icons.Default.Park + "appliance" in n || "kitchen" in n -> Icons.Default.Kitchen + "exterior" in n || "roof" in n -> Icons.Default.Roofing + "interior" in n -> Icons.Default.Weekend + "electric" in n -> Icons.Default.Bolt + "clean" in n -> Icons.Default.CleaningServices + else -> Icons.Default.Home + } +} + +private fun colorForCategory(name: String): Color { + // Deterministic rotation across five palette accents so each category + // renders a distinct card border. Cheap hash-mod mapping; stable within + // a session for a given category name. + val palette = listOf( + Color(0xFF07A0C3), // primary blue-green + Color(0xFFF5A623), // amber + Color(0xFF34C759), // green + Color(0xFFAF52DE), // purple + Color(0xFF0055A5) // cerulean + ) + val idx = kotlin.math.abs(name.hashCode()) % palette.size + return palette[idx] +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/OnboardingViewModel.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/OnboardingViewModel.kt index 4b16156..44c23a3 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/OnboardingViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/OnboardingViewModel.kt @@ -4,12 +4,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.tt.honeyDue.data.DataManager import com.tt.honeyDue.models.AuthResponse +import com.tt.honeyDue.models.BulkCreateTasksRequest import com.tt.honeyDue.models.LoginRequest import com.tt.honeyDue.models.RegisterRequest import com.tt.honeyDue.models.ResidenceCreateRequest import com.tt.honeyDue.models.TaskCreateRequest import com.tt.honeyDue.models.TaskSuggestionsResponse -import com.tt.honeyDue.models.TaskTemplate +import com.tt.honeyDue.models.TaskTemplatesGroupedResponse import com.tt.honeyDue.models.VerifyEmailRequest import com.tt.honeyDue.network.ApiResult import com.tt.honeyDue.network.APILayer @@ -80,15 +81,17 @@ class OnboardingViewModel : ViewModel() { private val _joinResidenceState = MutableStateFlow>(ApiResult.Idle) val joinResidenceState: StateFlow> = _joinResidenceState - // Task creation state + // Task creation state (bulk create) private val _createTasksState = MutableStateFlow>(ApiResult.Idle) val createTasksState: StateFlow> = _createTasksState - // Regional templates state - private val _regionalTemplates = MutableStateFlow>>(ApiResult.Idle) - val regionalTemplates: StateFlow>> = _regionalTemplates + // Grouped templates for the Browse tab on the First-Task screen + private val _templatesGroupedState = MutableStateFlow>(ApiResult.Idle) + val templatesGroupedState: StateFlow> = _templatesGroupedState - // ZIP code entered during location step (persisted on residence) + // ZIP code entered during onboarding (persisted on residence). Still + // collected so the suggestion service can factor in the user's climate + // zone as its 15th scoring condition. private val _postalCode = MutableStateFlow("") val postalCode: StateFlow = _postalCode @@ -396,9 +399,15 @@ class OnboardingViewModel : ViewModel() { } /** - * Create selected tasks during onboarding + * Create the user's selected tasks in a single atomic bulk request. + * The server runs the inserts inside one transaction, so either every + * entry lands or none do — no risk of a half-populated kanban board + * if the network flakes mid-batch. + * + * [residenceId] overrides every entry's residence_id on the server side; + * pass the just-created residence's ID. */ - fun createTasks(taskRequests: List) { + fun createTasks(residenceId: Int, taskRequests: List) { viewModelScope.launch { if (taskRequests.isEmpty()) { _createTasksState.value = ApiResult.Success(Unit) @@ -407,31 +416,28 @@ class OnboardingViewModel : ViewModel() { _createTasksState.value = ApiResult.Loading - var successCount = 0 - for (request in taskRequests) { - val result = APILayer.createTask(request) - if (result is ApiResult.Success) { - successCount++ - } - } - - _createTasksState.value = if (successCount > 0) { - ApiResult.Success(Unit) - } else { - ApiResult.Error("Failed to create tasks") + val request = BulkCreateTasksRequest( + residenceId = residenceId, + tasks = taskRequests + ) + _createTasksState.value = when (val result = APILayer.bulkCreateTasks(request)) { + is ApiResult.Success -> ApiResult.Success(Unit) + is ApiResult.Error -> ApiResult.Error(result.message, result.code) + is ApiResult.Loading -> ApiResult.Loading + is ApiResult.Idle -> ApiResult.Idle } } } /** - * Load regional templates by ZIP code (backend resolves ZIP → state → climate zone). - * Also stores the ZIP code for later use when creating the residence. + * Load the flat template catalog grouped by category. Feeds the Browse + * tab on the First-Task screen; no caching special-case because + * APILayer.getTaskTemplatesGrouped already reads from DataManager first. */ - fun loadRegionalTemplates(zip: String) { - _postalCode.value = zip + fun loadTemplatesGrouped(forceRefresh: Boolean = false) { viewModelScope.launch { - _regionalTemplates.value = ApiResult.Loading - _regionalTemplates.value = APILayer.getRegionalTemplates(zip = zip) + _templatesGroupedState.value = ApiResult.Loading + _templatesGroupedState.value = APILayer.getTaskTemplatesGrouped(forceRefresh = forceRefresh) } } @@ -456,7 +462,7 @@ class OnboardingViewModel : ViewModel() { _createResidenceState.value = ApiResult.Idle _joinResidenceState.value = ApiResult.Idle _createTasksState.value = ApiResult.Idle - _regionalTemplates.value = ApiResult.Idle + _templatesGroupedState.value = ApiResult.Idle _postalCode.value = "" _heatingType.value = null _coolingType.value = null diff --git a/iosApp/HoneyDueTests/DataManagerExtendedTests.swift b/iosApp/HoneyDueTests/DataManagerExtendedTests.swift index 2f49404..6c28565 100644 --- a/iosApp/HoneyDueTests/DataManagerExtendedTests.swift +++ b/iosApp/HoneyDueTests/DataManagerExtendedTests.swift @@ -42,9 +42,7 @@ extension DataLayerTests { iconAndroid: "", tags: tags, displayOrder: 0, - isActive: true, - regionId: nil, - regionName: nil + isActive: true ) } diff --git a/iosApp/iosApp/Analytics/AnalyticsEvent.swift b/iosApp/iosApp/Analytics/AnalyticsEvent.swift index 64c096a..a48aa8a 100644 --- a/iosApp/iosApp/Analytics/AnalyticsEvent.swift +++ b/iosApp/iosApp/Analytics/AnalyticsEvent.swift @@ -17,6 +17,16 @@ enum AnalyticsEvent { // MARK: - Task case taskCreated(residenceId: Int32) + // MARK: - Onboarding (First Task screen) + // Event names must stay in lockstep with + // composeApp/.../analytics/Analytics.kt so PostHog funnels join cleanly + // across iOS and Android. + case onboardingSuggestionsLoaded(count: Int, profileCompleteness: Double) + case onboardingSuggestionAccepted(templateId: Int32, relevanceScore: Double) + case onboardingBrowseTemplateAccepted(templateId: Int32, categoryId: Int32?) + case onboardingTasksCreated(count: Int) + case onboardingTaskStepSkipped(reason: String) + // MARK: - Contractor case contractorCreated case contractorShared @@ -64,6 +74,26 @@ enum AnalyticsEvent { case .taskCreated(let residenceId): return ("task_created", ["residence_id": residenceId]) + // Onboarding + case .onboardingSuggestionsLoaded(let count, let completeness): + return ("onboarding_suggestions_loaded", [ + "count": count, + "profile_completeness": completeness + ]) + case .onboardingSuggestionAccepted(let templateId, let relevance): + return ("onboarding_suggestion_accepted", [ + "template_id": templateId, + "relevance_score": relevance + ]) + case .onboardingBrowseTemplateAccepted(let templateId, let categoryId): + var props: [String: Any] = ["template_id": templateId] + if let categoryId { props["category_id"] = categoryId } + return ("onboarding_browse_template_accepted", props) + case .onboardingTasksCreated(let count): + return ("onboarding_tasks_created", ["count": count]) + case .onboardingTaskStepSkipped(let reason): + return ("onboarding_task_step_skipped", ["reason": reason]) + // Contractor case .contractorCreated: return ("contractor_created", nil) diff --git a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift index c335042..f674036 100644 --- a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift @@ -7,178 +7,117 @@ enum OnboardingTaskTab: String, CaseIterable { case browse = "Browse All" } -/// Screen 6: First task prompt with suggested templates - Content only (no navigation bar) +/// First-task onboarding content — pure server-driven, no hardcoded catalog. +/// +/// The "For You" tab shows scored suggestions from +/// `GET /api/tasks/suggestions/`. The "Browse All" tab shows the full +/// template catalog from `GET /api/tasks/templates/grouped/`. Both tabs +/// render loading, error, and empty states with a Retry + Skip option — +/// if the network is flaky, users can still finish onboarding and add +/// tasks later. +/// +/// Task creation goes through the single transactional bulk endpoint, +/// so either every selection lands or none do. struct OnboardingFirstTaskContent: View { var residenceName: String var onTaskAdded: () -> Void - @StateObject private var viewModel = TaskViewModel() - @ObservedObject private var dataManager = DataManagerObservable.shared + @StateObject private var vm = OnboardingTasksViewModel() @ObservedObject private var onboardingState = OnboardingState.shared - @State private var selectedTasks: Set = [] - @State private var isCreatingTasks = false - @State private var expandedCategories: Set = [] - @State private var isAnimating = false + + @State private var selectedIds: Set = [] @State private var selectedTab: OnboardingTaskTab = .forYou - @State private var forYouTemplates: [OnboardingTaskTemplate] = [] - @State private var isLoadingSuggestions = false + @State private var expandedCategoryIds: Set = [] + @State private var isAnimating = false + @Environment(\.colorScheme) var colorScheme - // No task selection limit — users can add as many as they want + private var selectedCount: Int { selectedIds.count } - /// 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 colour palette, rotated deterministically by category id so + /// each category card has visual distinction without coupling to names. + private static let categoryPalette: [Color] = [ + .appPrimary, + .appAccent, + .appSecondary, + Color(hex: "#34C759") ?? .green, + Color(hex: "#AF52DE") ?? .purple ] - /// 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 - - /// Task categories for the Browse tab - private var taskCategories: [OnboardingTaskCategory] { - if let cached = taskCategoriesCache { return cached } - return fallbackCategories + /// Icon derivation — hand-picked SF Symbols bucketed by category-name + /// keyword. Falls back to `wrench.and.screwdriver.fill`. + private static func icon(for name: String) -> String { + let lower = name.lowercased() + if lower.contains("hvac") || lower.contains("climate") { return "thermometer.medium" } + if lower.contains("safety") || lower.contains("security") { return "shield.checkered" } + if lower.contains("plumb") || lower.contains("water") { return "drop.fill" } + if lower.contains("outdoor") || lower.contains("yard") || lower.contains("landscap") { return "leaf.fill" } + if lower.contains("appliance") || lower.contains("kitchen") { return "refrigerator.fill" } + if lower.contains("exterior") || lower.contains("roof") { return "house.lodge.fill" } + if lower.contains("interior") { return "house.fill" } + if lower.contains("electric") { return "bolt.fill" } + if lower.contains("clean") { return "sparkles" } + return "wrench.and.screwdriver.fill" } - /// Convert API TaskTemplate list into OnboardingTaskCategory groups - private func categoriesFromAPI(_ templates: [TaskTemplate]) -> [OnboardingTaskCategory] { - // Group by category name - var grouped: [String: [OnboardingTaskTemplate]] = [:] - var order: [String] = [] + private static func color(for categoryId: Int32?) -> Color { + let palette = Self.categoryPalette + let id = categoryId ?? 0 + let index = Int(abs(Int64(id))) % palette.count + return palette[index] + } - 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() + /// Groups built from the server's grouped-templates response. Empty + /// until the catalog loads; the view shows its own loading / error + /// states so an empty array here is safe to render as nothing. + private var browseCategories: [OnboardingTaskCategory] { + guard let grouped = vm.grouped else { return [] } + return grouped.categories.map { group in + // Fall back to a negative hash-derived id for "Uncategorized" + // so the view's Identifiable conformance stays stable without + // colliding with any real category id. + let rawId = group.categoryId?.int32Value + let stableId: Int32 = rawId ?? Int32(truncatingIfNeeded: abs(group.categoryName.hashValue)) * -1 return OnboardingTaskCategory( - name: name, - icon: Self.categoryIcons[catKey] ?? "wrench.fill", - color: Self.categoryColors[catKey] ?? .appPrimary, - tasks: tasks + id: stableId, + name: group.categoryName.isEmpty ? "Uncategorized" : group.categoryName, + icon: Self.icon(for: group.categoryName), + color: Self.color(for: rawId), + tasks: group.templates.map { Self.template(from: $0, categoryName: group.categoryName) } ) } } - /// Hardcoded fallback categories when no API templates are available - private let fallbackCategories: [OnboardingTaskCategory] = [ - OnboardingTaskCategory( - name: "HVAC & Climate", - icon: "thermometer.medium", - color: Color.appPrimary, - tasks: [ - OnboardingTaskTemplate(icon: "fanblades.fill", title: "Change HVAC Filter", category: "hvac", frequency: "monthly", color: Color.appPrimary), - OnboardingTaskTemplate(icon: "air.conditioner.horizontal.fill", title: "Schedule AC Tune-Up", category: "hvac", frequency: "yearly", color: Color.appPrimary), - OnboardingTaskTemplate(icon: "flame.fill", title: "Inspect Furnace", category: "hvac", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange), - OnboardingTaskTemplate(icon: "wind", title: "Clean Air Ducts", category: "hvac", frequency: "yearly", color: Color.appSecondary) - ] - ), - OnboardingTaskCategory( - name: "Safety & Security", - icon: "shield.checkered", - color: Color.appError, - tasks: [ - OnboardingTaskTemplate(icon: "smoke.fill", title: "Test Smoke Detectors", category: "safety", frequency: "monthly", color: Color.appError), - OnboardingTaskTemplate(icon: "dot.radiowaves.left.and.right", title: "Check CO Detectors", category: "safety", frequency: "monthly", color: Color.appError), - OnboardingTaskTemplate(icon: "flame.fill", title: "Inspect Fire Extinguisher", category: "safety", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange), - OnboardingTaskTemplate(icon: "lock.fill", title: "Test Garage Door Safety", category: "safety", frequency: "monthly", color: Color.appSecondary) - ] - ), - OnboardingTaskCategory( - name: "Plumbing", - icon: "drop.fill", - color: Color.appSecondary, - tasks: [ - OnboardingTaskTemplate(icon: "drop.fill", title: "Check for Leaks", category: "plumbing", frequency: "monthly", color: Color.appSecondary), - OnboardingTaskTemplate(icon: "bolt.horizontal.fill", title: "Flush Water Heater", category: "plumbing", frequency: "yearly", color: Color(hex: "#FF9500") ?? .orange), - OnboardingTaskTemplate(icon: "wrench.and.screwdriver.fill", title: "Clean Faucet Aerators", category: "plumbing", frequency: "quarterly", color: Color.appPrimary), - OnboardingTaskTemplate(icon: "arrow.down.circle.fill", title: "Snake Drains", category: "plumbing", frequency: "quarterly", color: Color.appTextSecondary) - ] - ), - OnboardingTaskCategory( - name: "Outdoor & Lawn", - icon: "leaf.fill", - color: Color(hex: "#34C759") ?? .green, - tasks: [ - OnboardingTaskTemplate(icon: "leaf.fill", title: "Lawn Care", category: "landscaping", frequency: "weekly", color: Color(hex: "#34C759") ?? .green), - OnboardingTaskTemplate(icon: "cloud.rain.fill", title: "Clean Gutters", category: "exterior", frequency: "semiannually", color: Color.appSecondary), - OnboardingTaskTemplate(icon: "sun.max.fill", title: "Check Sprinkler System", category: "landscaping", frequency: "monthly", color: Color(hex: "#FF9500") ?? .orange), - OnboardingTaskTemplate(icon: "scissors", title: "Trim Trees & Shrubs", category: "landscaping", frequency: "quarterly", color: Color(hex: "#34C759") ?? .green) - ] - ), - OnboardingTaskCategory( - name: "Appliances", - icon: "refrigerator.fill", - color: Color.appAccent, - tasks: [ - OnboardingTaskTemplate(icon: "refrigerator.fill", title: "Clean Refrigerator Coils", category: "appliances", frequency: "semiannually", color: Color.appAccent), - OnboardingTaskTemplate(icon: "washer.fill", title: "Clean Washing Machine", category: "appliances", frequency: "monthly", color: Color.appSecondary), - OnboardingTaskTemplate(icon: "dishwasher.fill", title: "Clean Dishwasher Filter", category: "appliances", frequency: "monthly", color: Color.appPrimary), - OnboardingTaskTemplate(icon: "oven.fill", title: "Deep Clean Oven", category: "appliances", frequency: "quarterly", color: Color(hex: "#FF6B35") ?? .orange) - ] - ), - OnboardingTaskCategory( - name: "General Home", - icon: "house.fill", - color: Color(hex: "#AF52DE") ?? .purple, - tasks: [ - OnboardingTaskTemplate(icon: "paintbrush.fill", title: "Touch Up Paint", category: "interior", frequency: "yearly", color: Color(hex: "#AF52DE") ?? .purple), - OnboardingTaskTemplate(icon: "lightbulb.fill", title: "Replace Light Bulbs", category: "electrical", frequency: "monthly", color: Color.appAccent), - OnboardingTaskTemplate(icon: "door.left.hand.closed", title: "Lubricate Door Hinges", category: "interior", frequency: "yearly", color: Color.appTextSecondary), - OnboardingTaskTemplate(icon: "window.vertical.closed", title: "Clean Window Tracks", category: "interior", frequency: "semiannually", color: Color.appPrimary) - ] + /// Flat template lookup keyed by backend id. Drives the submit path + /// without caring which tab supplied the selection. + private var templateLookup: [Int32: OnboardingTaskTemplate] { + var map: [Int32: OnboardingTaskTemplate] = [:] + for category in browseCategories { + for template in category.tasks { map[template.id] = template } + } + for suggestion in vm.suggestions { + let t = suggestion.template + if map[t.id] == nil { + map[t.id] = Self.template( + from: t, + categoryName: t.category?.name ?? "Suggested" + ) + } + } + return map + } + + private static func template(from t: TaskTemplate, categoryName: String) -> OnboardingTaskTemplate { + OnboardingTaskTemplate( + id: t.id, + title: t.title, + description: t.description_.isEmpty ? nil : t.description_, + categoryId: t.categoryId?.int32Value, + frequencyId: t.frequencyId?.int32Value, + frequencyLabel: t.frequency?.displayName ?? "One time", + icon: t.iconIos.isEmpty ? Self.icon(for: categoryName) : t.iconIos, + color: Self.color(for: t.categoryId?.int32Value) ) - ] - - private var allTasks: [OnboardingTaskTemplate] { - taskCategories.flatMap { $0.tasks } - } - - private var selectedCount: Int { - selectedTasks.count - } - - private var isAtMaxSelection: Bool { - false } var body: some View { @@ -186,575 +125,534 @@ struct OnboardingFirstTaskContent: View { WarmGradientBackground() .a11yDecorative() - // 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) - } - .a11yDecorative() + backgroundBlobs + .a11yDecorative() VStack(spacing: 0) { - ScrollViewReader { proxy in ScrollView(showsIndicators: false) { VStack(spacing: OrganicSpacing.comfortable) { - // Header with celebration - VStack(spacing: 16) { - ZStack { - // Celebration circles - Circle() - .fill( - RadialGradient( - colors: [Color.appPrimary.opacity(0.15), Color.clear], - center: .center, - startRadius: 30, - endRadius: 80 - ) - ) - .frame(width: 140, height: 140) - .offset(x: -15, y: -15) - .scaleEffect(isAnimating ? 1.1 : 1.0) - .animation( - isAnimating - ? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true) - : .default, - value: isAnimating - ) + headerBlock + selectionChip - Circle() - .fill( - RadialGradient( - colors: [Color.appAccent.opacity(0.15), Color.clear], - center: .center, - startRadius: 30, - endRadius: 80 - ) - ) - .frame(width: 140, height: 140) - .offset(x: 15, y: 15) - .scaleEffect(isAnimating ? 0.95 : 1.05) - .animation( - isAnimating - ? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5) - : .default, - value: isAnimating - ) - - // Party icon - ZStack { - Circle() - .fill( - LinearGradient( - colors: [Color.appPrimary, Color.appSecondary], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 80, height: 80) - - Image(systemName: "party.popper.fill") - .font(.system(size: 36)) - .foregroundColor(.white) - } - .naturalShadow(.pronounced) - } - - Text("You're all set up!") - .font(.system(size: 26, weight: .bold, design: .rounded)) - .foregroundColor(Color.appTextPrimary) - .a11yHeader() - - Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!") - .font(.system(size: 15, weight: .medium)) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - .lineSpacing(4) - } - .padding(.top, OrganicSpacing.comfortable) - - // Selection counter chip - HStack(spacing: 8) { - Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill") - .foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary) - - Text("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(Color.appPrimary) - } - .padding(.horizontal, 18) - .padding(.vertical, 10) - .background(Color.appPrimary.opacity(0.1)) - .clipShape(Capsule()) - .animation(.spring(response: 0.3), value: selectedCount) - .accessibilityLabel("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected") - - // Tab bar OnboardingTaskTabBar(selectedTab: $selectedTab) .padding(.horizontal, OrganicSpacing.comfortable) - // Tab content switch selectedTab { case .forYou: - // For You tab — personalized suggestions - ForYouTasksTab( - forYouTemplates: forYouTemplates, - isLoading: isLoadingSuggestions, - selectedTasks: $selectedTasks, - isAtMaxSelection: isAtMaxSelection, - hasResidence: onboardingState.createdResidenceId != nil - ) - .padding(.horizontal, OrganicSpacing.comfortable) - + forYouTab + .padding(.horizontal, OrganicSpacing.comfortable) case .browse: - // Browse tab — existing category browser - VStack(spacing: 12) { - ForEach(taskCategories) { category in - OrganicTaskCategorySection( - category: category, - selectedTasks: $selectedTasks, - isExpanded: expandedCategories.contains(category.name), - isAtMaxSelection: isAtMaxSelection, - onToggleExpand: { - let isExpanding = !expandedCategories.contains(category.name) - withAnimation(.spring(response: 0.3)) { - if expandedCategories.contains(category.name) { - expandedCategories.remove(category.name) - } else { - 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) - - // Quick add all popular - Button(action: selectPopularTasks) { - HStack(spacing: 8) { - Image(systemName: "sparkles") - .font(.system(size: 16, weight: .semibold)) - - Text("Add Most Popular") - .font(.system(size: 16, weight: .semibold)) - } - .foregroundStyle( - LinearGradient( - colors: [Color.appPrimary, Color.appAccent], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(maxWidth: .infinity) - .frame(height: 56) - .background( - LinearGradient( - colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)], - startPoint: .leading, - endPoint: .trailing - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke( - LinearGradient( - colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)], - startPoint: .leading, - endPoint: .trailing - ), - lineWidth: 1.5 - ) - ) - } - .padding(.horizontal, OrganicSpacing.comfortable) - .a11yButton("Add popular tasks") + browseTab + .padding(.horizontal, OrganicSpacing.comfortable) } } - .padding(.bottom, 140) // Space for button + .padding(.bottom, 140) // Space for bottom action } - } // ScrollViewReader - // Bottom action area - VStack(spacing: 14) { - Button(action: addSelectedTasks) { - HStack(spacing: 10) { - if isCreatingTasks { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - } else { - Text(selectedCount > 0 ? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue" : "Skip for Now") - .font(.system(size: 17, weight: .bold)) + bottomActionArea + } - Image(systemName: "arrow.right") - .font(.system(size: 16, weight: .bold)) - } - } - .frame(maxWidth: .infinity) - .frame(height: 56) - .foregroundColor(Color.appTextOnPrimary) - .background( - selectedCount > 0 - ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing)) - : AnyShapeStyle(Color.appTextSecondary.opacity(0.5)) - ) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .naturalShadow(selectedCount > 0 ? .medium : .subtle) - } - .disabled(isCreatingTasks) - .animation(.easeInOut(duration: 0.2), value: selectedCount) - } - .padding(.horizontal, OrganicSpacing.comfortable) - .padding(.bottom, OrganicSpacing.airy) - .background( - LinearGradient( - colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary], - startPoint: .top, - endPoint: .center - ) - .frame(height: 60) - .offset(y: -60) - , alignment: .top - ) + if let submitError = vm.submitError { + bannerError(submitError) } } .onAppear { isAnimating = true - // Build and cache categories once to preserve stable UUIDs - if taskCategoriesCache == nil { - taskCategoriesCache = fallbackCategories - } - // Expand first category by default - if let first = taskCategories.first?.name { - expandedCategories.insert(first) - } - // Build "For You" suggestions based on home profile - buildForYouSuggestions() + Task { await loadInitial() } } .onDisappear { isAnimating = false } } - private func selectPopularTasks() { - withAnimation(.spring(response: 0.3)) { - 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) { - selectedTasks.insert(task.id) + // MARK: - Sub-views + + private var headerBlock: some View { + VStack(spacing: 16) { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [Color.appPrimary.opacity(0.15), Color.clear], + center: .center, + startRadius: 30, + endRadius: 80 + ) + ) + .frame(width: 140, height: 140) + .offset(x: -15, y: -15) + .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: 140, height: 140) + .offset(x: 15, y: 15) + + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.appPrimary, Color.appSecondary], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 80, height: 80) + + Image(systemName: "party.popper.fill") + .font(.system(size: 36)) + .foregroundColor(.white) + } + .naturalShadow(.pronounced) + } + + Text("You're all set up!") + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + .a11yHeader() + + Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + .padding(.top, OrganicSpacing.comfortable) + } + + private var selectionChip: some View { + HStack(spacing: 8) { + Image(systemName: selectedCount > 0 ? "checkmark.seal.fill" : "checkmark.circle.fill") + .foregroundColor(selectedCount > 0 ? Color.appAccent : Color.appPrimary) + + Text("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background(Color.appPrimary.opacity(0.1)) + .clipShape(Capsule()) + .animation(.spring(response: 0.3), value: selectedCount) + .accessibilityLabel("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected") + } + + @ViewBuilder + private var forYouTab: some View { + if vm.isLoadingSuggestions { + OnboardingLoadingPane(message: "Finding tasks for your home...") + } else if let errorMessage = vm.suggestionsError { + OnboardingErrorPane( + headline: "Couldn't load your suggestions", + message: errorMessage, + retry: retrySuggestions, + skip: { skip(reason: "network_error_for_you") }, + secondary: vm.grouped != nil ? .init(label: "Browse All", action: { selectedTab = .browse }) : nil + ) + } else if vm.suggestions.isEmpty && vm.suggestionsAttempted { + OnboardingEmptyPane( + message: "No personalised suggestions yet — browse the full catalog or skip this step.", + primary: .init(label: vm.grouped != nil ? "Browse All" : "Skip", action: { + if vm.grouped != nil { + selectedTab = .browse + } else { + skip(reason: "no_suggestions_no_catalog") + } + }) + ) + } else { + VStack(spacing: 0) { + ForEach(vm.suggestions, id: \.template.id) { suggestion in + let isSelected = selectedIds.contains(suggestion.template.id) + OnboardingSuggestionRow( + suggestion: suggestion, + isSelected: isSelected, + onTap: { + toggleSuggestion(suggestion, wasSelected: isSelected) + } + ) + if suggestion.template.id != vm.suggestions.last?.template.id { + Divider().padding(.leading, 60) + } + } + } + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .naturalShadow(.subtle) + } + } + + @ViewBuilder + private var browseTab: some View { + if vm.isLoadingGrouped && vm.grouped == nil { + OnboardingLoadingPane(message: "Loading the task catalog...") + } else if let errorMessage = vm.groupedError, vm.grouped == nil { + OnboardingErrorPane( + headline: "Couldn't load the task catalog", + message: errorMessage, + retry: retryGrouped, + skip: { skip(reason: "network_error_browse") } + ) + } else if browseCategories.isEmpty { + OnboardingEmptyPane( + message: "No templates available right now.", + primary: .init(label: "Skip", action: { skip(reason: "empty_catalog") }) + ) + } else { + VStack(spacing: 12) { + ForEach(browseCategories) { category in + OnboardingCategorySection( + category: category, + selectedIds: selectedIds, + isExpanded: expandedCategoryIds.contains(category.id), + onToggleExpand: { + withAnimation(.spring(response: 0.3)) { + if expandedCategoryIds.contains(category.id) { + expandedCategoryIds.remove(category.id) + } else { + expandedCategoryIds.insert(category.id) + } + } + }, + onToggleTask: { template in + toggleBrowse(template) + } + ) + } } } } - /// Build personalized "For You" suggestions based on the home profile selections - private func buildForYouSuggestions() { - var suggestions: [ForYouSuggestion] = [] + private var bottomActionArea: some View { + VStack(spacing: 14) { + Button(action: submit) { + HStack(spacing: 10) { + if vm.isSubmitting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Text(selectedCount > 0 + ? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue" + : "Skip for Now") + .font(.system(size: 17, weight: .bold)) - let state = onboardingState - - // HVAC-related suggestions based on heating/cooling type - if state.pendingHeatingType != nil || state.pendingCoolingType != nil { - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "fanblades.fill", title: "Change HVAC Filter", category: "hvac", frequency: "monthly", color: .appPrimary), - relevance: .great, reason: "Based on your HVAC system" - )) + Image(systemName: "arrow.right") + .font(.system(size: 16, weight: .bold)) + } + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + selectedCount > 0 + ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing)) + : AnyShapeStyle(Color.appTextSecondary.opacity(0.5)) + ) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(selectedCount > 0 ? .medium : .subtle) + } + .disabled(vm.isSubmitting) + .animation(.easeInOut(duration: 0.2), value: selectedCount) } - if state.pendingHeatingType == "gas_furnace" || state.pendingHeatingType == "boiler" { - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "flame.fill", title: "Inspect Furnace", category: "hvac", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange), - relevance: .great, reason: "You have a gas system" - )) + .padding(.horizontal, OrganicSpacing.comfortable) + .padding(.bottom, OrganicSpacing.airy) + } + + private var backgroundBlobs: some View { + 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) } - if state.pendingCoolingType == "central_ac" || state.pendingCoolingType == "heat_pump" { - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "air.conditioner.horizontal.fill", title: "Schedule AC Tune-Up", category: "hvac", frequency: "yearly", color: .appPrimary), - relevance: .great, reason: "Central cooling needs annual service" - )) + } + + private func bannerError(_ message: String) -> some View { + VStack { + Spacer() + Text(message) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.appError.opacity(0.95)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.horizontal, 16) + .padding(.bottom, 80) + .transition(.move(edge: .bottom).combined(with: .opacity)) } + } - // Water heater - if state.pendingWaterHeaterType != nil { - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "bolt.horizontal.fill", title: "Flush Water Heater", category: "plumbing", frequency: "yearly", color: Color(hex: "#FF9500") ?? .orange), - relevance: state.pendingWaterHeaterType?.contains("tank") == true ? .great : .good, - reason: "Extends water heater life" - )) - } + // MARK: - Actions - // Pool - if state.pendingHasPool { - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "figure.pool.swim", title: "Check Pool Chemistry", category: "exterior", frequency: "weekly", color: .appSecondary), - relevance: .great, reason: "You have a pool" - )) - } - - // Sprinklers - if state.pendingHasSprinklerSystem { - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "sprinkler.and.droplets.fill", title: "Check Sprinkler System", category: "landscaping", frequency: "monthly", color: Color(hex: "#34C759") ?? .green), - relevance: .great, reason: "You have sprinklers" - )) - } - - // Fireplace - if state.pendingHasFireplace { - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "fireplace.fill", title: "Inspect Chimney & Fireplace", category: "interior", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange), - relevance: .great, reason: "You have a fireplace" - )) - } - - // Garage - if state.pendingHasGarage { - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "lock.fill", title: "Test Garage Door Safety", category: "safety", frequency: "monthly", color: .appSecondary), - relevance: .good, reason: "You have a garage" - )) - } - - // Basement - if state.pendingHasBasement { - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "drop.fill", title: "Check Basement for Moisture", category: "interior", frequency: "monthly", color: .appSecondary), - relevance: .good, reason: "You have a basement" - )) - } - - // Septic - if state.pendingHasSeptic { - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "drop.triangle.fill", title: "Schedule Septic Inspection", category: "plumbing", frequency: "yearly", color: .appPrimary), - relevance: .great, reason: "You have a septic system" - )) - } - - // Attic - if state.pendingHasAttic { - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "arrow.up.square.fill", title: "Inspect Attic Insulation", category: "interior", frequency: "yearly", color: Color(hex: "#AF52DE") ?? .purple), - relevance: .good, reason: "You have an attic" - )) - } - - // Roof-based - if state.pendingRoofType != nil { - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "cloud.rain.fill", title: "Clean Gutters", category: "exterior", frequency: "semiannually", color: .appSecondary), - relevance: .great, reason: "Protects your roof" - )) - } - - // Always-recommended essentials (lower priority) - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "smoke.fill", title: "Test Smoke Detectors", category: "safety", frequency: "monthly", color: .appError), - relevance: .good, reason: "Essential safety task" - )) - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "drop.fill", title: "Check for Leaks", category: "plumbing", frequency: "monthly", color: .appSecondary), - relevance: .good, reason: "Prevents water damage" - )) - - // Landscaping - if state.pendingLandscapingType == "lawn" || state.pendingLandscapingType == "garden" || state.pendingLandscapingType == "mixed" { - suggestions.append(ForYouSuggestion( - template: OnboardingTaskTemplate(icon: "leaf.fill", title: "Lawn Care", category: "landscaping", frequency: "weekly", color: Color(hex: "#34C759") ?? .green), - relevance: .good, reason: "Based on your landscaping" - )) - } - - // Sort: great first, then good; deduplicate by title - var seen = Set() - let sorted = suggestions - .sorted { $0.relevance.rawValue > $1.relevance.rawValue } - .filter { seen.insert($0.template.title).inserted } - - forYouTemplates = sorted.map { $0.template } - - // If we have personalized suggestions, default to For You tab - if !forYouTemplates.isEmpty && hasAnyHomeProfileData() { - selectedTab = .forYou + private func loadInitial() async { + // Always hit the catalog endpoint — both tabs benefit (For You falls + // back to "Browse All" button on empty suggestions). + async let groupedTask: Void = vm.loadGrouped() + if let residenceId = onboardingState.createdResidenceId { + async let suggestionsTask: Void = vm.loadSuggestions(residenceId: residenceId) + _ = await (groupedTask, suggestionsTask) } else { - selectedTab = .browse + _ = await groupedTask + } + + // Expand the first category by default once the catalog resolves. + if expandedCategoryIds.isEmpty, let first = browseCategories.first { + expandedCategoryIds.insert(first.id) } } - /// Check if user filled in any home profile data - private func hasAnyHomeProfileData() -> Bool { - let s = onboardingState - return s.pendingHeatingType != nil || - s.pendingCoolingType != nil || - s.pendingWaterHeaterType != nil || - s.pendingRoofType != nil || - s.pendingHasPool || - s.pendingHasSprinklerSystem || - s.pendingHasSeptic || - s.pendingHasFireplace || - s.pendingHasGarage || - s.pendingHasBasement || - s.pendingHasAttic || - s.pendingExteriorType != nil || - s.pendingFlooringPrimary != nil || - s.pendingLandscapingType != nil - } - - private func addSelectedTasks() { - // If no tasks selected, just skip - if selectedTasks.isEmpty { - onTaskAdded() - return - } - - // Get the residence ID from OnboardingState (set during residence creation) + private func retrySuggestions() { guard let residenceId = onboardingState.createdResidenceId else { - print("🏠 ONBOARDING: No residence ID found in OnboardingState, skipping task creation") - onTaskAdded() + skip(reason: "no_residence") + return + } + Task { await vm.loadSuggestions(residenceId: residenceId) } + } + + private func retryGrouped() { + Task { await vm.loadGrouped(forceRefresh: true) } + } + + private func skip(reason: String) { + AnalyticsManager.shared.track(.onboardingTaskStepSkipped(reason: reason)) + onTaskAdded() + } + + private func toggleSuggestion(_ suggestion: TaskSuggestionResponse, wasSelected: Bool) { + withAnimation(.spring(response: 0.2)) { + let id = suggestion.template.id + if wasSelected { + selectedIds.remove(id) + } else { + selectedIds.insert(id) + AnalyticsManager.shared.track(.onboardingSuggestionAccepted( + templateId: id, + relevanceScore: suggestion.relevanceScore + )) + } + } + } + + private func toggleBrowse(_ template: OnboardingTaskTemplate) { + withAnimation(.spring(response: 0.2)) { + if selectedIds.contains(template.id) { + selectedIds.remove(template.id) + } else { + selectedIds.insert(template.id) + AnalyticsManager.shared.track(.onboardingBrowseTemplateAccepted( + templateId: template.id, + categoryId: template.categoryId + )) + } + } + } + + private func submit() { + // Zero selection → pure skip path, no network call. + if selectedIds.isEmpty { + skip(reason: "user_skip") return } - isCreatingTasks = true - - // Collect from both browse and For You templates - let allAvailable = allTasks + forYouTemplates - let selectedTemplates = allAvailable.filter { selectedTasks.contains($0.id) } - // Deduplicate by title (same task might exist in both tabs) - var seenTitles = Set() - let uniqueTemplates = selectedTemplates.filter { seenTitles.insert($0.title).inserted } - var completedCount = 0 - let totalCount = uniqueTemplates.count - - // Safety: if no templates matched (shouldn't happen), skip - if totalCount == 0 { - isCreatingTasks = false - onTaskAdded() + guard let residenceId = onboardingState.createdResidenceId else { + // No residence means onboarding partially failed earlier; just + // advance so the user isn't stuck. + skip(reason: "no_residence") return } - // Format today's date as YYYY-MM-DD for the API let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.dateFormat = "yyyy-MM-dd" let todayString = dateFormatter.string(from: Date()) - print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residenceId)") - - for template in uniqueTemplates { - // Look up category ID from DataManager - let categoryId: Int32? = { - return dataManager.taskCategories.first { $0.name.caseInsensitiveCompare(template.category) == .orderedSame }?.id - }() - - // Look up frequency ID from DataManager - let frequencyId: Int32? = { - return dataManager.taskFrequencies.first { $0.name.caseInsensitiveCompare(template.frequency) == .orderedSame }?.id - }() - - print("🏠 ONBOARDING: Creating task '\(template.title)' - categoryId=\(String(describing: categoryId)), frequencyId=\(String(describing: frequencyId))") - - let request = TaskCreateRequest( + let lookup = templateLookup + let requests: [TaskCreateRequest] = selectedIds.compactMap { id in + guard let template = lookup[id] else { return nil } + return TaskCreateRequest( residenceId: residenceId, title: template.title, - description: nil, - categoryId: categoryId.map { KotlinInt(int: $0) }, + description: template.description, + categoryId: template.categoryId.map { KotlinInt(int: $0) }, priorityId: nil, inProgress: false, - frequencyId: frequencyId.map { KotlinInt(int: $0) }, + frequencyId: template.frequencyId.map { KotlinInt(int: $0) }, customIntervalDays: nil, assignedToId: nil, dueDate: todayString, estimatedCost: nil, - contractorId: nil + contractorId: nil, + templateId: KotlinInt(int: template.id) ) + } - viewModel.createTask(request: request) { success in - completedCount += 1 - print("🏠 ONBOARDING: Task '\(template.title)' creation: \(success ? "SUCCESS" : "FAILED") (\(completedCount)/\(totalCount))") - - if completedCount == totalCount { - Task { @MainActor in - self.isCreatingTasks = false - self.onTaskAdded() - } - } + Task { + let ok = await vm.submit(residenceId: residenceId, requests: requests) + if ok { + onTaskAdded() } + // On failure vm.submitError is displayed by the overlay banner; + // the user can retry by tapping the button again. } } } -// MARK: - Onboarding Task Category Model +// MARK: - Adapter models struct OnboardingTaskCategory: Identifiable { - let id = UUID() + let id: Int32 let name: String let icon: String let color: Color let tasks: [OnboardingTaskTemplate] } -// MARK: - Organic Task Category Section +struct OnboardingTaskTemplate: Identifiable { + let id: Int32 // backend TaskTemplate.id — sent as template_id + let title: String + let description: String? + let categoryId: Int32? + let frequencyId: Int32? + let frequencyLabel: String + let icon: String + let color: Color +} -private struct OrganicTaskCategorySection: View { +// MARK: - Tab bar + +private struct OnboardingTaskTabBar: View { + @Binding var selectedTab: OnboardingTaskTab + + var body: some View { + Picker("", selection: $selectedTab) { + ForEach(OnboardingTaskTab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + } +} + +// MARK: - Suggestion row (For You) + +private struct OnboardingSuggestionRow: View { + let suggestion: TaskSuggestionResponse + let isSelected: Bool + var onTap: () -> Void + + private var relevancePercent: Int { + Int((suggestion.relevanceScore * 100).rounded()) + } + + var body: some View { + Button(action: onTap) { + HStack(spacing: 14) { + ZStack { + Circle() + .stroke(isSelected ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 2) + .frame(width: 28, height: 28) + if isSelected { + Circle() + .fill(Color.appPrimary) + .frame(width: 28, height: 28) + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.white) + } + } + + VStack(alignment: .leading, spacing: 2) { + Text(suggestion.template.title) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextPrimary) + Text(suggestion.template.frequencyDisplay) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + if let reason = suggestion.matchReasons.first { + Text(reason) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Color.appPrimary.opacity(0.85)) + } + } + + Spacer() + + Text("\(relevancePercent)%") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(Color.appPrimary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.appPrimary.opacity(0.12)) + .clipShape(Capsule()) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(suggestion.template.title), \(suggestion.template.frequencyDisplay), \(relevancePercent)% match") + .accessibilityValue(isSelected ? "selected" : "not selected") + } +} + +// MARK: - Category section (Browse) + +private struct OnboardingCategorySection: View { let category: OnboardingTaskCategory - @Binding var selectedTasks: Set + let selectedIds: Set let isExpanded: Bool - let isAtMaxSelection: Bool var onToggleExpand: () -> Void - - @Environment(\.colorScheme) var colorScheme + var onToggleTask: (OnboardingTaskTemplate) -> Void private var selectedInCategory: Int { - category.tasks.filter { selectedTasks.contains($0.id) }.count + category.tasks.filter { selectedIds.contains($0.id) }.count } var body: some View { VStack(spacing: 0) { - // Category header Button(action: onToggleExpand) { HStack(spacing: 14) { - // Category icon ZStack { Circle() .fill( @@ -765,22 +663,18 @@ private struct OrganicTaskCategorySection: View { ) ) .frame(width: 44, height: 44) - Image(systemName: category.icon) .font(.system(size: 18, weight: .medium)) .foregroundColor(.white) } .naturalShadow(.subtle) - // Category name Text(category.name) .font(.system(size: 16, weight: .semibold)) .foregroundColor(Color.appTextPrimary) - .a11yHeader() Spacer() - // Selection badge if selectedInCategory > 0 { Text("\(selectedInCategory)") .font(.system(size: 12, weight: .bold)) @@ -790,19 +684,12 @@ private struct OrganicTaskCategorySection: View { .clipShape(Circle()) } - // Chevron Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .font(.system(size: 12, weight: .semibold)) .foregroundColor(Color.appTextSecondary) } .padding(14) - .background( - ZStack { - Color.appBackgroundSecondary - GrainTexture(opacity: 0.01) - } - ) - .clipShape(RoundedRectangle(cornerRadius: isExpanded ? 18 : 18, style: .continuous)) + .background(Color.appBackgroundSecondary) .clipShape( UnevenRoundedRectangle( topLeadingRadius: 18, @@ -815,29 +702,18 @@ private struct OrganicTaskCategorySection: View { } .buttonStyle(.plain) - // Expanded tasks if isExpanded { VStack(spacing: 0) { ForEach(category.tasks) { task in - let taskIsSelected = selectedTasks.contains(task.id) - OrganicTaskTemplateRow( + let isSelected = selectedIds.contains(task.id) + OnboardingTemplateRow( template: task, - isSelected: taskIsSelected, - isDisabled: isAtMaxSelection && !taskIsSelected, - onTap: { - withAnimation(.spring(response: 0.2)) { - if taskIsSelected { - selectedTasks.remove(task.id) - } else if !isAtMaxSelection { - selectedTasks.insert(task.id) - } - } - } + isSelected: isSelected, + categoryColor: category.color, + onTap: { onToggleTask(task) } ) - if task.id != category.tasks.last?.id { - Divider() - .padding(.leading, 60) + Divider().padding(.leading, 60) } } } @@ -857,266 +733,200 @@ private struct OrganicTaskCategorySection: View { } } -// MARK: - Organic Task Template Row - -private struct OrganicTaskTemplateRow: View { +private struct OnboardingTemplateRow: View { let template: OnboardingTaskTemplate let isSelected: Bool - let isDisabled: Bool + let categoryColor: Color var onTap: () -> Void var body: some View { Button(action: onTap) { HStack(spacing: 14) { - // Checkbox ZStack { Circle() - .stroke(isSelected ? template.color : Color.appTextSecondary.opacity(isDisabled ? 0.15 : 0.3), lineWidth: 2) + .stroke(isSelected ? categoryColor : Color.appTextSecondary.opacity(0.3), lineWidth: 2) .frame(width: 28, height: 28) - if isSelected { Circle() - .fill(template.color) + .fill(categoryColor) .frame(width: 28, height: 28) - Image(systemName: "checkmark") .font(.system(size: 12, weight: .bold)) .foregroundColor(.white) } } - // Task info VStack(alignment: .leading, spacing: 2) { Text(template.title) .font(.system(size: 15, weight: .medium)) - .foregroundColor(isDisabled ? Color.appTextSecondary.opacity(0.5) : Color.appTextPrimary) - - Text(template.frequency.capitalized) + .foregroundColor(Color.appTextPrimary) + Text(template.frequencyLabel) .font(.system(size: 12, weight: .medium)) - .foregroundColor(Color.appTextSecondary.opacity(isDisabled ? 0.5 : 1)) + .foregroundColor(Color.appTextSecondary) } Spacer() - // Task icon Image(systemName: template.icon) .font(.system(size: 18, weight: .medium)) - .foregroundColor(template.color.opacity(isDisabled ? 0.3 : 0.6)) + .foregroundColor(categoryColor.opacity(0.6)) } .padding(.horizontal, 14) .padding(.vertical, 12) .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(isDisabled) - .accessibilityLabel("\(template.title), \(template.frequency.capitalized)") + .accessibilityLabel("\(template.title), \(template.frequencyLabel)") .accessibilityValue(isSelected ? "selected" : "not selected") } } -// MARK: - Onboarding Task Template Model +// MARK: - Shared panes -struct OnboardingTaskTemplate: Identifiable { - let id = UUID() - let icon: String - let title: String - let category: String - let frequency: String - let color: Color +struct OnboardingSecondaryAction { + let label: String + let action: () -> Void } -// MARK: - For You Suggestion Model - -enum SuggestionRelevance: Int { - case good = 1 - case great = 2 -} - -struct ForYouSuggestion { - let template: OnboardingTaskTemplate - let relevance: SuggestionRelevance - let reason: String -} - -// MARK: - Tab Bar - -private struct OnboardingTaskTabBar: View { - @Binding var selectedTab: OnboardingTaskTab +private struct OnboardingLoadingPane: View { + let message: String var body: some View { - Picker("", selection: $selectedTab) { - ForEach(OnboardingTaskTab.allCases, id: \.self) { tab in - Text(tab.rawValue).tag(tab) - } + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary)) + .scaleEffect(1.2) + Text(message) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) } - .pickerStyle(.segmented) + .frame(maxWidth: .infinity) + .padding(.vertical, 40) } } -// MARK: - For You Tasks Tab - -private struct ForYouTasksTab: View { - let forYouTemplates: [OnboardingTaskTemplate] - let isLoading: Bool - @Binding var selectedTasks: Set - let isAtMaxSelection: Bool - let hasResidence: Bool +private struct OnboardingErrorPane: View { + let headline: String + let message: String + var retry: () -> Void + var skip: () -> Void + var secondary: OnboardingSecondaryAction? var body: some View { - if isLoading { - VStack(spacing: 16) { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary)) - .scaleEffect(1.2) - Text("Generating suggestions...") - .font(.system(size: 15, weight: .medium)) - .foregroundColor(Color.appTextSecondary) + VStack(spacing: 14) { + ZStack { + Circle() + .fill(Color.appError.opacity(0.1)) + .frame(width: 64, height: 64) + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 28)) + .foregroundColor(Color.appError) } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } else if forYouTemplates.isEmpty { - VStack(spacing: 16) { - ZStack { - Circle() - .fill(Color.appPrimary.opacity(0.1)) - .frame(width: 64, height: 64) - Image(systemName: "sparkles") - .font(.system(size: 28)) - .foregroundColor(Color.appPrimary) + + Text(headline) + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(Color.appTextPrimary) + .multilineTextAlignment(.center) + + Text(message) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .lineSpacing(3) + + HStack(spacing: 10) { + Button(action: skip) { + Text("Skip for now") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appTextSecondary) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.appTextSecondary.opacity(0.08)) + .clipShape(Capsule()) } + .buttonStyle(.plain) - Text("No personalized suggestions yet") - .font(.system(size: 17, weight: .semibold)) - .foregroundColor(Color.appTextPrimary) - - Text("Try the Browse tab to explore tasks by category,\nor add home details for better suggestions.") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - .lineSpacing(4) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 30) - .padding(.horizontal, 16) - .background( - ZStack { - Color.appBackgroundSecondary - GrainTexture(opacity: 0.01) - } - ) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .naturalShadow(.subtle) - } else { - VStack(spacing: 0) { - ForEach(Array(forYouTemplates.enumerated()), id: \.element.id) { index, template in - let isSelected = selectedTasks.contains(template.id) - let isDisabled = isAtMaxSelection && !isSelected - - ForYouSuggestionRow( - template: template, - isSelected: isSelected, - isDisabled: isDisabled, - relevance: index < 3 ? .great : .good, - onTap: { - withAnimation(.spring(response: 0.2)) { - if isSelected { - selectedTasks.remove(template.id) - } else if !isAtMaxSelection { - selectedTasks.insert(template.id) - } - } - } - ) - - if index < forYouTemplates.count - 1 { - Divider() - .padding(.leading, 60) + if let secondary { + Button(action: secondary.action) { + Text(secondary.label) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appPrimary) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.appPrimary.opacity(0.12)) + .clipShape(Capsule()) } + .buttonStyle(.plain) } + + Button(action: retry) { + HStack(spacing: 6) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 13, weight: .bold)) + Text("Retry") + .font(.system(size: 14, weight: .bold)) + } + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.appPrimary) + .clipShape(Capsule()) + } + .buttonStyle(.plain) } - .background( - ZStack { - Color.appBackgroundSecondary - GrainTexture(opacity: 0.01) - } - ) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - .naturalShadow(.subtle) } + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + .padding(.horizontal, 20) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .naturalShadow(.subtle) } } -// MARK: - For You Suggestion Row - -private struct ForYouSuggestionRow: View { - let template: OnboardingTaskTemplate - let isSelected: Bool - let isDisabled: Bool - let relevance: SuggestionRelevance - var onTap: () -> Void +private struct OnboardingEmptyPane: View { + let message: String + let primary: OnboardingSecondaryAction var body: some View { - Button(action: onTap) { - HStack(spacing: 14) { - // Checkbox - ZStack { - Circle() - .stroke(isSelected ? template.color : Color.appTextSecondary.opacity(isDisabled ? 0.15 : 0.3), lineWidth: 2) - .frame(width: 28, height: 28) + VStack(spacing: 16) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 64, height: 64) + Image(systemName: "tray") + .font(.system(size: 28)) + .foregroundColor(Color.appPrimary) + } - if isSelected { - Circle() - .fill(template.color) - .frame(width: 28, height: 28) + Text(message) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .lineSpacing(3) - Image(systemName: "checkmark") - .font(.system(size: 12, weight: .bold)) - .foregroundColor(.white) - } - } - - // Task icon - Image(systemName: template.icon) - .font(.system(size: 18, weight: .medium)) - .foregroundColor(template.color.opacity(isDisabled ? 0.3 : 0.8)) - .frame(width: 24) - - // Task info - VStack(alignment: .leading, spacing: 2) { - Text(template.title) - .font(.system(size: 15, weight: .medium)) - .foregroundColor(isDisabled ? Color.appTextSecondary.opacity(0.5) : Color.appTextPrimary) - - Text(template.frequency.capitalized) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(Color.appTextSecondary.opacity(isDisabled ? 0.5 : 1)) - } - - Spacer() - - // Relevance badge - Text(relevance == .great ? "Great match" : "Good match") - .font(.system(size: 10, weight: .bold)) - .foregroundColor(relevance == .great ? Color.appPrimary : Color.appTextSecondary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - (relevance == .great ? Color.appPrimary : Color.appTextSecondary).opacity(0.1) - ) + Button(action: primary.action) { + Text(primary.label) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 24) + .padding(.vertical, 10) + .background(Color.appPrimary) .clipShape(Capsule()) } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .contentShape(Rectangle()) + .buttonStyle(.plain) } - .buttonStyle(.plain) - .disabled(isDisabled) - .accessibilityLabel("\(template.title), \(template.frequency.capitalized)") - .accessibilityValue(isSelected ? "selected" : "not selected") + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + .padding(.horizontal, 20) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .naturalShadow(.subtle) } } -// MARK: - Legacy wrapper with navigation bar (for backwards compatibility) +// MARK: - Legacy wrapper with navigation bar struct OnboardingFirstTaskView: View { var residenceName: String @@ -1128,10 +938,8 @@ struct OnboardingFirstTaskView: View { WarmGradientBackground() VStack(spacing: 0) { - // Navigation bar HStack { Spacer() - Button(action: onSkip) { Text("Skip") .font(.system(size: 15, weight: .medium)) diff --git a/iosApp/iosApp/Onboarding/OnboardingTasksViewModel.swift b/iosApp/iosApp/Onboarding/OnboardingTasksViewModel.swift new file mode 100644 index 0000000..56257f6 --- /dev/null +++ b/iosApp/iosApp/Onboarding/OnboardingTasksViewModel.swift @@ -0,0 +1,123 @@ +import Foundation +import ComposeApp + +/// Backs the First-Task onboarding screen. Owns the network calls for +/// personalised suggestions and the full template catalog, plus the bulk +/// task-create submission. No hardcoded suggestion rules or fallback +/// catalog — when the API fails the screen shows error+retry+skip. +/// +/// Mirrors the Android `OnboardingViewModel.suggestionsState` / +/// `templatesGroupedState` / `createTasksState` flows in a purely Swift +/// shape: calling `APILayer.shared.*` directly is more idiomatic here than +/// observing Kotlin StateFlows, and matches the pattern used by +/// `TaskViewModel.swift`. +@MainActor +final class OnboardingTasksViewModel: ObservableObject { + + // MARK: - Suggestions (For You tab) + @Published private(set) var suggestions: [TaskSuggestionResponse] = [] + @Published private(set) var profileCompleteness: Double = 0 + @Published private(set) var isLoadingSuggestions = false + @Published private(set) var suggestionsError: String? + /// True once `loadSuggestions` has returned any terminal state. + /// Used by the view to distinguish "haven't tried yet" from "tried and + /// returned empty". + @Published private(set) var suggestionsAttempted = false + + // MARK: - Grouped catalog (Browse All tab) + @Published private(set) var grouped: TaskTemplatesGroupedResponse? + @Published private(set) var isLoadingGrouped = false + @Published private(set) var groupedError: String? + + // MARK: - Submission + @Published private(set) var isSubmitting = false + @Published private(set) var submitError: String? + + // MARK: - Loads + + func loadSuggestions(residenceId: Int32) async { + if isLoadingSuggestions { return } + isLoadingSuggestions = true + suggestionsError = nil + + do { + let result = try await APILayer.shared.getTaskSuggestions(residenceId: residenceId) + if let success = result as? ApiResultSuccess, + let data = success.data { + suggestions = data.suggestions + profileCompleteness = data.profileCompleteness + AnalyticsManager.shared.track(.onboardingSuggestionsLoaded( + count: data.suggestions.count, + profileCompleteness: data.profileCompleteness + )) + } else if let error = ApiResultBridge.error(from: result) { + suggestionsError = ErrorMessageParser.parse(error.message) + } else { + suggestionsError = "Could not load suggestions." + } + } catch { + suggestionsError = ErrorMessageParser.parse(error.localizedDescription) + } + + isLoadingSuggestions = false + suggestionsAttempted = true + } + + func loadGrouped(forceRefresh: Bool = false) async { + if isLoadingGrouped { return } + isLoadingGrouped = true + groupedError = nil + + do { + let result = try await APILayer.shared.getTaskTemplatesGrouped(forceRefresh: forceRefresh) + if let success = result as? ApiResultSuccess, + let data = success.data { + grouped = data + } else if let error = ApiResultBridge.error(from: result) { + groupedError = ErrorMessageParser.parse(error.message) + } else { + groupedError = "Could not load templates." + } + } catch { + groupedError = ErrorMessageParser.parse(error.localizedDescription) + } + + isLoadingGrouped = false + } + + // MARK: - Submit + + /// Posts the picked tasks in a single transaction via the bulk endpoint. + /// Returns true on any successful server response (including empty + /// selections, which short-circuit without a network call). False is + /// terminal — the caller should show the stored `submitError`. + func submit(residenceId: Int32, requests: [TaskCreateRequest]) async -> Bool { + if requests.isEmpty { + AnalyticsManager.shared.track(.onboardingTaskStepSkipped(reason: "user_skip")) + return true + } + + isSubmitting = true + submitError = nil + + let request = BulkCreateTasksRequest(residenceId: residenceId, tasks: requests) + defer { isSubmitting = false } + + do { + let result = try await APILayer.shared.bulkCreateTasks(request: request) + if result is ApiResultSuccess { + AnalyticsManager.shared.track(.onboardingTasksCreated(count: requests.count)) + return true + } else if let error = ApiResultBridge.error(from: result) { + submitError = ErrorMessageParser.parse(error.message) + return false + } else { + submitError = "Could not create tasks." + return false + } + } catch { + submitError = ErrorMessageParser.parse(error.localizedDescription) + return false + } + } +} diff --git a/iosApp/iosApp/Subviews/Task/TaskCard.swift b/iosApp/iosApp/Subviews/Task/TaskCard.swift index 93b5fa3..1c10944 100644 --- a/iosApp/iosApp/Subviews/Task/TaskCard.swift +++ b/iosApp/iosApp/Subviews/Task/TaskCard.swift @@ -336,6 +336,7 @@ private struct TaskCardBackground: View { isCancelled: false, isArchived: false, parentTaskId: nil, + templateId: nil, completionCount: 0, kanbanColumn: nil, completions: [], diff --git a/iosApp/iosApp/Subviews/Task/TasksSection.swift b/iosApp/iosApp/Subviews/Task/TasksSection.swift index 1f031ed..0f6240e 100644 --- a/iosApp/iosApp/Subviews/Task/TasksSection.swift +++ b/iosApp/iosApp/Subviews/Task/TasksSection.swift @@ -141,6 +141,7 @@ struct SwipeHintView: View { isCancelled: false, isArchived: false, parentTaskId: nil, + templateId: nil, completionCount: 0, kanbanColumn: nil, completions: [], @@ -182,6 +183,7 @@ struct SwipeHintView: View { isCancelled: false, isArchived: false, parentTaskId: nil, + templateId: nil, completionCount: 3, kanbanColumn: nil, completions: [], diff --git a/iosApp/iosApp/Task/TaskFormView.swift b/iosApp/iosApp/Task/TaskFormView.swift index 1212476..f4f7a9a 100644 --- a/iosApp/iosApp/Task/TaskFormView.swift +++ b/iosApp/iosApp/Task/TaskFormView.swift @@ -495,7 +495,8 @@ struct TaskFormView: View { assignedToId: nil, dueDate: dueDateString, estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0), - contractorId: nil + contractorId: nil, + templateId: nil ) viewModel.updateTask(id: task.id, request: request) { success in @@ -532,7 +533,8 @@ struct TaskFormView: View { assignedToId: nil, dueDate: dueDateString, estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0), - contractorId: nil + contractorId: nil, + templateId: nil ) viewModel.createTask(request: request) { success in