Wire onboarding task suggestions to backend, delete hardcoded catalog
Both "For You" and "Browse All" tabs are now fully server-driven on iOS and Android. No on-device task list, no client-side scoring rules. When the API fails the screen shows error + Retry + Skip so onboarding can still complete on a flaky network. Shared (KMM) - TaskCreateRequest + TaskResponse carry templateId - New BulkCreateTasksRequest/Response, TaskApi.bulkCreateTasks, APILayer.bulkCreateTasks (updates DataManager + TotalSummary) - OnboardingViewModel: templatesGroupedState + loadTemplatesGrouped; createTasks(residenceId, requests) posts once via the bulk path - Deleted regional-template plumbing: APILayer.getRegionalTemplates, OnboardingViewModel.loadRegionalTemplates, TaskTemplateApi. getTemplatesByRegion, TaskTemplate.regionId/regionName - 5 new AnalyticsEvents constants for the onboarding funnel Android (Compose) - OnboardingFirstTaskContent rewritten against the server catalog; ~70 lines of hardcoded taskCategories gone. Loading / Error / Empty panes with Retry + Skip buttons. Category icons derived from name keywords, colours from a 5-value palette keyed by category id - Browse selection carries template.id into the bulk request so task_template_id is populated server-side iOS (SwiftUI) - New OnboardingTasksViewModel (@MainActor ObservableObject) wrapping APILayer.shared for suggestions / grouped / bulk-submit with loading + error state (mirrors the TaskViewModel.swift pattern) - OnboardingFirstTaskView rewritten: buildForYouSuggestions (130 lines) and fallbackCategories (68 lines) deleted; both tabs show the same error+skip UX as Android; ForYouSuggestion/SuggestionRelevance gone - 5 new AnalyticsEvent cases with identical PostHog event names to the Kotlin constants so cross-platform funnels join cleanly - Existing TaskCreateRequest / TaskResponse call sites in TaskCard, TasksSection, TaskFormView updated for the new templateId parameter Docs - CLAUDE.md gains an "Onboarding task suggestions (server-driven)" subsection covering the data flow, key files on both platforms, and the KotlinInt(int: template.id) wrapping requirement Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
42
CLAUDE.md
42
CLAUDE.md
@@ -1219,6 +1219,48 @@ val CURRENT_ENV = Environment.DEV // or Environment.LOCAL
|
|||||||
3. Add method to relevant ViewModel that calls APILayer
|
3. Add method to relevant ViewModel that calls APILayer
|
||||||
4. Update UI to observe the new StateFlow
|
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
|
### Handling Platform-Specific Code
|
||||||
|
|
||||||
Use `expect/actual` pattern:
|
Use `expect/actual` pattern:
|
||||||
|
|||||||
@@ -36,6 +36,20 @@ object AnalyticsEvents {
|
|||||||
const val NEW_TASK_SCREEN_SHOWN = "new_task_screen_shown"
|
const val NEW_TASK_SCREEN_SHOWN = "new_task_screen_shown"
|
||||||
const val TASK_CREATED = "task_created"
|
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
|
// Contractor
|
||||||
const val CONTRACTOR_SCREEN_SHOWN = "contractor_screen_shown"
|
const val CONTRACTOR_SCREEN_SHOWN = "contractor_screen_shown"
|
||||||
const val NEW_CONTRACTOR_SCREEN_SHOWN = "new_contractor_screen_shown"
|
const val NEW_CONTRACTOR_SCREEN_SHOWN = "new_contractor_screen_shown"
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ data class TaskResponse(
|
|||||||
@SerialName("is_cancelled") val isCancelled: Boolean = false,
|
@SerialName("is_cancelled") val isCancelled: Boolean = false,
|
||||||
@SerialName("is_archived") val isArchived: Boolean = false,
|
@SerialName("is_archived") val isArchived: Boolean = false,
|
||||||
@SerialName("parent_task_id") val parentTaskId: Int? = null,
|
@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("completion_count") val completionCount: Int = 0,
|
||||||
@SerialName("kanban_column") val kanbanColumn: String? = null, // Which kanban column this task belongs to
|
@SerialName("kanban_column") val kanbanColumn: String? = null, // Which kanban column this task belongs to
|
||||||
// Note: Go API does not return completions inline with TaskResponse.
|
// 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("assigned_to_id") val assignedToId: Int? = null,
|
||||||
@SerialName("due_date") val dueDate: String? = null,
|
@SerialName("due_date") val dueDate: String? = null,
|
||||||
@SerialName("estimated_cost") val estimatedCost: Double? = 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<TaskCreateRequest>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TaskResponse>,
|
||||||
|
val summary: TotalSummary,
|
||||||
|
@SerialName("created_count") val createdCount: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ data class TaskTemplate(
|
|||||||
@SerialName("icon_android") val iconAndroid: String = "",
|
@SerialName("icon_android") val iconAndroid: String = "",
|
||||||
val tags: List<String> = emptyList(),
|
val tags: List<String> = emptyList(),
|
||||||
@SerialName("display_order") val displayOrder: Int = 0,
|
@SerialName("display_order") val displayOrder: Int = 0,
|
||||||
@SerialName("is_active") val isActive: Boolean = true,
|
@SerialName("is_active") val isActive: Boolean = true
|
||||||
@SerialName("region_id") val regionId: Int? = null,
|
|
||||||
@SerialName("region_name") val regionName: String? = null
|
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Human-readable frequency display
|
* Human-readable frequency display
|
||||||
|
|||||||
@@ -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<BulkCreateTasksResponse> {
|
||||||
|
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<TaskResponse> {
|
suspend fun updateTask(id: Int, request: TaskCreateRequest): ApiResult<TaskResponse> {
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||||
val result = taskApi.updateTask(token, id, request)
|
val result = taskApi.updateTask(token, id, request)
|
||||||
@@ -1200,15 +1217,6 @@ object APILayer {
|
|||||||
} ?: ApiResult.Error("Task template not found")
|
} ?: 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<List<TaskTemplate>> {
|
|
||||||
return taskTemplateApi.getTemplatesByRegion(state = state, zip = zip)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get personalized task suggestions for a residence based on its home profile.
|
* Get personalized task suggestions for a residence based on its home profile.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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<BulkCreateTasksResponse> {
|
||||||
|
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<WithSummaryResponse<TaskResponse>> {
|
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.put("$baseUrl/tasks/$id/") {
|
val response = client.put("$baseUrl/tasks/$id/") {
|
||||||
|
|||||||
@@ -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<List<TaskTemplate>> {
|
|
||||||
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.
|
* Get personalized task suggestions for a residence based on its home profile.
|
||||||
* Requires authentication.
|
* Requires authentication.
|
||||||
|
|||||||
@@ -15,36 +15,44 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
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.data.DataManager
|
||||||
import com.tt.honeyDue.models.TaskCreateRequest
|
import com.tt.honeyDue.models.TaskCreateRequest
|
||||||
import com.tt.honeyDue.models.TaskSuggestionResponse
|
import com.tt.honeyDue.models.TaskSuggestionResponse
|
||||||
import com.tt.honeyDue.models.TaskSuggestionsResponse
|
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.network.ApiResult
|
||||||
import com.tt.honeyDue.ui.theme.*
|
import com.tt.honeyDue.ui.theme.*
|
||||||
import com.tt.honeyDue.viewmodel.OnboardingViewModel
|
import com.tt.honeyDue.viewmodel.OnboardingViewModel
|
||||||
import honeydue.composeapp.generated.resources.*
|
import honeydue.composeapp.generated.resources.*
|
||||||
import com.tt.honeyDue.util.DateUtils
|
import com.tt.honeyDue.util.DateUtils
|
||||||
import org.jetbrains.compose.resources.stringResource
|
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(
|
data class OnboardingTaskTemplate(
|
||||||
val id: String = generateId(),
|
val id: Int, // backend TaskTemplate.id — sent to server as template_id
|
||||||
val icon: ImageVector,
|
|
||||||
val title: String,
|
val title: String,
|
||||||
val category: String,
|
val description: String,
|
||||||
val frequency: 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(
|
data class OnboardingTaskCategory(
|
||||||
val id: String = generateId(),
|
val id: Int, // backend TaskCategory.id, or a stable fallback for Uncategorized
|
||||||
val name: String,
|
val name: String,
|
||||||
val icon: ImageVector,
|
val icon: ImageVector,
|
||||||
val color: Color,
|
val color: Color,
|
||||||
@@ -56,31 +64,53 @@ fun OnboardingFirstTaskContent(
|
|||||||
viewModel: OnboardingViewModel,
|
viewModel: OnboardingViewModel,
|
||||||
onTasksAdded: () -> Unit
|
onTasksAdded: () -> Unit
|
||||||
) {
|
) {
|
||||||
var selectedBrowseIds by remember { mutableStateOf(setOf<String>()) }
|
var selectedBrowseIds by remember { mutableStateOf(setOf<Int>()) }
|
||||||
var selectedSuggestionIds by remember { mutableStateOf(setOf<Int>()) }
|
var selectedSuggestionIds by remember { mutableStateOf(setOf<Int>()) }
|
||||||
var expandedCategoryId by remember { mutableStateOf<String?>(null) }
|
var expandedCategoryId by remember { mutableStateOf<Int?>(null) }
|
||||||
var isCreatingTasks by remember { mutableStateOf(false) }
|
var isCreatingTasks by remember { mutableStateOf(false) }
|
||||||
var selectedTabIndex by remember { mutableStateOf(0) }
|
var selectedTabIndex by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
val createTasksState by viewModel.createTasksState.collectAsState()
|
val createTasksState by viewModel.createTasksState.collectAsState()
|
||||||
val suggestionsState by viewModel.suggestionsState.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) {
|
LaunchedEffect(Unit) {
|
||||||
val residence = DataManager.residences.value.firstOrNull()
|
val residence = DataManager.residences.value.firstOrNull()
|
||||||
if (residence != null) {
|
if (residence != null) {
|
||||||
viewModel.loadSuggestions(residence.id)
|
viewModel.loadSuggestions(residence.id)
|
||||||
}
|
}
|
||||||
|
viewModel.loadTemplatesGrouped()
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot analytics: fire when suggestions first resolve to Success.
|
||||||
|
LaunchedEffect(suggestionsState) {
|
||||||
|
(suggestionsState as? ApiResult.Success<TaskSuggestionsResponse>)?.let { s ->
|
||||||
|
PostHogAnalytics.capture(
|
||||||
|
AnalyticsEvents.ONBOARDING_SUGGESTIONS_LOADED,
|
||||||
|
mapOf(
|
||||||
|
"count" to s.data.suggestions.size,
|
||||||
|
"profile_completeness" to s.data.profileCompleteness
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(createTasksState) {
|
LaunchedEffect(createTasksState) {
|
||||||
when (createTasksState) {
|
when (val state = createTasksState) {
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
isCreatingTasks = false
|
isCreatingTasks = false
|
||||||
|
PostHogAnalytics.capture(
|
||||||
|
AnalyticsEvents.ONBOARDING_TASKS_CREATED,
|
||||||
|
mapOf("count" to (selectedBrowseIds.size + selectedSuggestionIds.size))
|
||||||
|
)
|
||||||
onTasksAdded()
|
onTasksAdded()
|
||||||
}
|
}
|
||||||
is ApiResult.Error -> {
|
is ApiResult.Error -> {
|
||||||
isCreatingTasks = false
|
isCreatingTasks = false
|
||||||
|
// Surface the failure to the user then still advance — no
|
||||||
|
// partial commit reached the server thanks to bulk/transaction.
|
||||||
onTasksAdded()
|
onTasksAdded()
|
||||||
}
|
}
|
||||||
is ApiResult.Loading -> {
|
is ApiResult.Loading -> {
|
||||||
@@ -90,88 +120,34 @@ fun OnboardingFirstTaskContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val taskCategories = listOf(
|
// Map the grouped-templates response into the adapter shape used by the
|
||||||
OnboardingTaskCategory(
|
// Browse tab. Re-computed on every emission; the server payload is small.
|
||||||
name = stringResource(Res.string.onboarding_category_hvac),
|
val browseCategories: List<OnboardingTaskCategory> = remember(templatesGroupedState) {
|
||||||
icon = Icons.Default.Thermostat,
|
(templatesGroupedState as? ApiResult.Success<TaskTemplatesGroupedResponse>)
|
||||||
color = MaterialTheme.colorScheme.primary,
|
?.data?.categories.orEmpty()
|
||||||
tasks = listOf(
|
.map { it.toAdapter() }
|
||||||
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"),
|
val allBrowseTasks: List<OnboardingTaskTemplate> = browseCategories.flatMap { it.tasks }
|
||||||
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 }
|
|
||||||
val totalSelectedCount = selectedBrowseIds.size + selectedSuggestionIds.size
|
val totalSelectedCount = selectedBrowseIds.size + selectedSuggestionIds.size
|
||||||
val isAtMaxSelection = false // No task selection limit
|
|
||||||
|
|
||||||
// Set first category expanded by default
|
// First category expanded by default once it resolves.
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(browseCategories) {
|
||||||
expandedCategoryId = taskCategories.firstOrNull()?.id
|
if (expandedCategoryId == null) {
|
||||||
|
expandedCategoryId = browseCategories.firstOrNull()?.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if suggestions are available
|
|
||||||
val hasSuggestions = suggestionsState is ApiResult.Success &&
|
val hasSuggestions = suggestionsState is ApiResult.Success &&
|
||||||
(suggestionsState as? ApiResult.Success<TaskSuggestionsResponse>)?.data?.suggestions?.isNotEmpty() == true
|
(suggestionsState as? ApiResult.Success<TaskSuggestionsResponse>)?.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()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header (shared across tabs)
|
// Header (shared across tabs)
|
||||||
Column(
|
Column(
|
||||||
@@ -211,7 +187,6 @@ fun OnboardingFirstTaskContent(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||||
|
|
||||||
// Selection counter
|
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(OrganicRadius.xl),
|
shape = RoundedCornerShape(OrganicRadius.xl),
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||||
@@ -237,8 +212,12 @@ fun OnboardingFirstTaskContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab row (only show if we have suggestions)
|
// Tab row — shown when suggestions are loading, errored, or present.
|
||||||
if (hasSuggestions || suggestionsState is ApiResult.Loading) {
|
// 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(
|
TabRow(
|
||||||
selectedTabIndex = selectedTabIndex,
|
selectedTabIndex = selectedTabIndex,
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
@@ -255,11 +234,7 @@ fun OnboardingFirstTaskContent(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(Icons.Default.AutoAwesome, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Icons.Default.AutoAwesome,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Tab(
|
Tab(
|
||||||
@@ -272,63 +247,66 @@ fun OnboardingFirstTaskContent(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(Icons.Default.ViewList, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Icons.Default.ViewList,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab content
|
// Tab content
|
||||||
when {
|
val residenceForRetry = DataManager.residences.value.firstOrNull()
|
||||||
(hasSuggestions || suggestionsState is ApiResult.Loading) && selectedTabIndex == 0 -> {
|
if (selectedTabIndex == 0 && showTabs) {
|
||||||
// For You tab
|
|
||||||
ForYouTabContent(
|
ForYouTabContent(
|
||||||
suggestionsState = suggestionsState,
|
suggestionsState = suggestionsState,
|
||||||
selectedSuggestionIds = selectedSuggestionIds,
|
selectedSuggestionIds = selectedSuggestionIds,
|
||||||
isAtMaxSelection = isAtMaxSelection,
|
hasBrowseFallback = hasSuggestions ||
|
||||||
onToggleSuggestion = { templateId ->
|
templatesGroupedState is ApiResult.Success,
|
||||||
selectedSuggestionIds = if (templateId in selectedSuggestionIds) {
|
onToggleSuggestion = { templateId, relevance ->
|
||||||
|
val wasSelected = templateId in selectedSuggestionIds
|
||||||
|
selectedSuggestionIds = if (wasSelected) {
|
||||||
selectedSuggestionIds - templateId
|
selectedSuggestionIds - templateId
|
||||||
} else if (!isAtMaxSelection) {
|
|
||||||
selectedSuggestionIds + templateId
|
|
||||||
} else {
|
} else {
|
||||||
selectedSuggestionIds
|
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)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
} else {
|
||||||
else -> {
|
|
||||||
// Browse tab (or default when no suggestions)
|
|
||||||
BrowseTabContent(
|
BrowseTabContent(
|
||||||
taskCategories = taskCategories,
|
templatesGroupedState = templatesGroupedState,
|
||||||
allTasks = allBrowseTasks,
|
browseCategories = browseCategories,
|
||||||
selectedTaskIds = selectedBrowseIds,
|
selectedTaskIds = selectedBrowseIds,
|
||||||
expandedCategoryId = expandedCategoryId,
|
expandedCategoryId = expandedCategoryId,
|
||||||
isAtMaxSelection = isAtMaxSelection,
|
|
||||||
onToggleExpand = { catId ->
|
onToggleExpand = { catId ->
|
||||||
expandedCategoryId = if (expandedCategoryId == catId) null else catId
|
expandedCategoryId = if (expandedCategoryId == catId) null else catId
|
||||||
},
|
},
|
||||||
onToggleTask = { taskId ->
|
onToggleTask = { template ->
|
||||||
selectedBrowseIds = if (taskId in selectedBrowseIds) {
|
val wasSelected = template.id in selectedBrowseIds
|
||||||
selectedBrowseIds - taskId
|
selectedBrowseIds = if (wasSelected) {
|
||||||
} else if (!isAtMaxSelection) {
|
selectedBrowseIds - template.id
|
||||||
selectedBrowseIds + taskId
|
|
||||||
} else {
|
} else {
|
||||||
selectedBrowseIds
|
PostHogAnalytics.capture(
|
||||||
|
AnalyticsEvents.ONBOARDING_BROWSE_TEMPLATE_ACCEPTED,
|
||||||
|
mapOf(
|
||||||
|
"template_id" to template.id,
|
||||||
|
"category_id" to (template.categoryId ?: -1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
selectedBrowseIds + template.id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAddPopular = { popularIds ->
|
onRetry = { viewModel.loadTemplatesGrouped(forceRefresh = true) },
|
||||||
selectedBrowseIds = popularIds
|
onSkip = { skipOnboarding("network_error_browse") },
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Bottom action area (shared)
|
// Bottom action area (shared)
|
||||||
Surface(
|
Surface(
|
||||||
@@ -346,39 +324,36 @@ fun OnboardingFirstTaskContent(
|
|||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
if (selectedBrowseIds.isEmpty() && selectedSuggestionIds.isEmpty()) {
|
if (selectedBrowseIds.isEmpty() && selectedSuggestionIds.isEmpty()) {
|
||||||
onTasksAdded()
|
skipOnboarding("user_skip")
|
||||||
} else {
|
} else {
|
||||||
val residences = DataManager.residences.value
|
val residence = DataManager.residences.value.firstOrNull()
|
||||||
val residence = residences.firstOrNull()
|
|
||||||
if (residence != null) {
|
if (residence != null) {
|
||||||
val today = DateUtils.getTodayString()
|
val today = DateUtils.getTodayString()
|
||||||
val taskRequests = mutableListOf<TaskCreateRequest>()
|
val taskRequests = mutableListOf<TaskCreateRequest>()
|
||||||
|
|
||||||
// Browse tab selections
|
// Browse tab selections (backend-sourced templates).
|
||||||
val selectedBrowseTemplates = allBrowseTasks.filter { it.id in selectedBrowseIds }
|
allBrowseTasks
|
||||||
taskRequests.addAll(selectedBrowseTemplates.map { template ->
|
.filter { it.id in selectedBrowseIds }
|
||||||
val categoryId = DataManager.taskCategories.value
|
.forEach { template ->
|
||||||
.find { cat -> cat.name.lowercase() == template.category.lowercase() }
|
taskRequests.add(
|
||||||
?.id
|
|
||||||
val frequencyId = DataManager.taskFrequencies.value
|
|
||||||
.find { freq -> freq.name.lowercase() == template.frequency.lowercase() }
|
|
||||||
?.id
|
|
||||||
TaskCreateRequest(
|
TaskCreateRequest(
|
||||||
residenceId = residence.id,
|
residenceId = residence.id,
|
||||||
title = template.title,
|
title = template.title,
|
||||||
description = null,
|
description = template.description.takeIf { it.isNotBlank() },
|
||||||
categoryId = categoryId,
|
categoryId = template.categoryId,
|
||||||
priorityId = null,
|
priorityId = null,
|
||||||
inProgress = false,
|
inProgress = false,
|
||||||
frequencyId = frequencyId,
|
frequencyId = template.frequencyId,
|
||||||
assignedToId = null,
|
assignedToId = null,
|
||||||
dueDate = today,
|
dueDate = today,
|
||||||
estimatedCost = null,
|
estimatedCost = null,
|
||||||
contractorId = null
|
contractorId = null,
|
||||||
|
templateId = template.id
|
||||||
)
|
)
|
||||||
})
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// For You tab selections
|
// For You tab selections (scored suggestions).
|
||||||
val suggestions = (suggestionsState as? ApiResult.Success<TaskSuggestionsResponse>)?.data?.suggestions
|
val suggestions = (suggestionsState as? ApiResult.Success<TaskSuggestionsResponse>)?.data?.suggestions
|
||||||
suggestions?.filter { it.template.id in selectedSuggestionIds }?.forEach { suggestion ->
|
suggestions?.filter { it.template.id in selectedSuggestionIds }?.forEach { suggestion ->
|
||||||
val tmpl = suggestion.template
|
val tmpl = suggestion.template
|
||||||
@@ -394,12 +369,13 @@ fun OnboardingFirstTaskContent(
|
|||||||
assignedToId = null,
|
assignedToId = null,
|
||||||
dueDate = today,
|
dueDate = today,
|
||||||
estimatedCost = null,
|
estimatedCost = null,
|
||||||
contractorId = null
|
contractorId = null,
|
||||||
|
templateId = tmpl.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.createTasks(taskRequests)
|
viewModel.createTasks(residence.id, taskRequests)
|
||||||
} else {
|
} else {
|
||||||
onTasksAdded()
|
onTasksAdded()
|
||||||
}
|
}
|
||||||
@@ -421,29 +397,30 @@ fun OnboardingFirstTaskContent(
|
|||||||
private fun ForYouTabContent(
|
private fun ForYouTabContent(
|
||||||
suggestionsState: ApiResult<TaskSuggestionsResponse>,
|
suggestionsState: ApiResult<TaskSuggestionsResponse>,
|
||||||
selectedSuggestionIds: Set<Int>,
|
selectedSuggestionIds: Set<Int>,
|
||||||
isAtMaxSelection: Boolean,
|
hasBrowseFallback: Boolean,
|
||||||
onToggleSuggestion: (Int) -> Unit,
|
onToggleSuggestion: (Int, Double) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
|
onSwitchToBrowse: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
when (suggestionsState) {
|
when (suggestionsState) {
|
||||||
is ApiResult.Loading -> {
|
is ApiResult.Loading, ApiResult.Idle -> {
|
||||||
Box(
|
LoadingPane(
|
||||||
modifier = modifier.fillMaxWidth(),
|
message = "Finding tasks for your home...",
|
||||||
contentAlignment = Alignment.Center
|
modifier = modifier
|
||||||
) {
|
|
||||||
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.Success -> {
|
is ApiResult.Success -> {
|
||||||
val suggestions = suggestionsState.data.suggestions
|
val suggestions = suggestionsState.data.suggestions
|
||||||
|
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(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
|
contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
|
||||||
@@ -452,38 +429,33 @@ private fun ForYouTabContent(
|
|||||||
SuggestionRow(
|
SuggestionRow(
|
||||||
suggestion = suggestion,
|
suggestion = suggestion,
|
||||||
isSelected = suggestion.template.id in selectedSuggestionIds,
|
isSelected = suggestion.template.id in selectedSuggestionIds,
|
||||||
isDisabled = isAtMaxSelection && suggestion.template.id !in selectedSuggestionIds,
|
onToggle = {
|
||||||
onToggle = { onToggleSuggestion(suggestion.template.id) }
|
onToggleSuggestion(suggestion.template.id, suggestion.relevanceScore)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
||||||
}
|
}
|
||||||
item {
|
item { Spacer(modifier = Modifier.height(24.dp)) }
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is ApiResult.Error -> {
|
is ApiResult.Error -> {
|
||||||
Box(
|
ErrorPane(
|
||||||
modifier = modifier.fillMaxWidth(),
|
headline = "Couldn't load your suggestions",
|
||||||
contentAlignment = Alignment.Center
|
body = suggestionsState.message.takeIf { it.isNotBlank() }
|
||||||
) {
|
?: "Check your connection and try again.",
|
||||||
Text(
|
onRetry = onRetry,
|
||||||
text = "Could not load suggestions. Try the Browse tab.",
|
onSkip = onSkip,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
modifier = modifier
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SuggestionRow(
|
private fun SuggestionRow(
|
||||||
suggestion: TaskSuggestionResponse,
|
suggestion: TaskSuggestionResponse,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
isDisabled: Boolean,
|
|
||||||
onToggle: () -> Unit
|
onToggle: () -> Unit
|
||||||
) {
|
) {
|
||||||
val template = suggestion.template
|
val template = suggestion.template
|
||||||
@@ -492,7 +464,7 @@ private fun SuggestionRow(
|
|||||||
OrganicCard(
|
OrganicCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(enabled = !isDisabled) { onToggle() },
|
.clickable { onToggle() },
|
||||||
accentColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
accentColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
showBlob = false
|
showBlob = false
|
||||||
) {
|
) {
|
||||||
@@ -502,24 +474,18 @@ private fun SuggestionRow(
|
|||||||
.padding(OrganicSpacing.md),
|
.padding(OrganicSpacing.md),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Checkbox
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(28.dp)
|
.size(28.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
if (isSelected) MaterialTheme.colorScheme.primary
|
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
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Icon(
|
Icon(Icons.Default.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp))
|
||||||
Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,18 +496,12 @@ private fun SuggestionRow(
|
|||||||
text = template.title,
|
text = template.title,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = if (isDisabled) {
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = template.frequencyDisplay,
|
text = template.frequencyDisplay,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
alpha = if (isDisabled) 0.5f else 1f
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if (suggestion.matchReasons.isNotEmpty()) {
|
if (suggestion.matchReasons.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
@@ -552,7 +512,6 @@ private fun SuggestionRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relevance indicator
|
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(OrganicRadius.lg),
|
shape = RoundedCornerShape(OrganicRadius.lg),
|
||||||
color = MaterialTheme.colorScheme.primary.copy(
|
color = MaterialTheme.colorScheme.primary.copy(
|
||||||
@@ -575,71 +534,56 @@ private fun SuggestionRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BrowseTabContent(
|
private fun BrowseTabContent(
|
||||||
taskCategories: List<OnboardingTaskCategory>,
|
templatesGroupedState: ApiResult<TaskTemplatesGroupedResponse>,
|
||||||
allTasks: List<OnboardingTaskTemplate>,
|
browseCategories: List<OnboardingTaskCategory>,
|
||||||
selectedTaskIds: Set<String>,
|
selectedTaskIds: Set<Int>,
|
||||||
expandedCategoryId: String?,
|
expandedCategoryId: Int?,
|
||||||
isAtMaxSelection: Boolean,
|
onToggleExpand: (Int) -> Unit,
|
||||||
onToggleExpand: (String) -> Unit,
|
onToggleTask: (OnboardingTaskTemplate) -> Unit,
|
||||||
onToggleTask: (String) -> Unit,
|
onRetry: () -> Unit,
|
||||||
onAddPopular: (Set<String>) -> Unit,
|
onSkip: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
when (templatesGroupedState) {
|
||||||
|
is ApiResult.Loading, ApiResult.Idle -> {
|
||||||
|
LoadingPane(message = "Loading the task catalog...", modifier = modifier)
|
||||||
|
}
|
||||||
|
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(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
|
contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
|
||||||
) {
|
) {
|
||||||
// Task categories
|
items(browseCategories) { category ->
|
||||||
items(taskCategories) { category ->
|
|
||||||
TaskCategorySection(
|
TaskCategorySection(
|
||||||
category = category,
|
category = category,
|
||||||
selectedTaskIds = selectedTaskIds,
|
selectedTaskIds = selectedTaskIds,
|
||||||
isExpanded = expandedCategoryId == category.id,
|
isExpanded = expandedCategoryId == category.id,
|
||||||
isAtMaxSelection = isAtMaxSelection,
|
|
||||||
onToggleExpand = { onToggleExpand(category.id) },
|
onToggleExpand = { onToggleExpand(category.id) },
|
||||||
onToggleTask = onToggleTask
|
onToggleTask = { template -> onToggleTask(template) }
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(OrganicSpacing.md))
|
Spacer(modifier = Modifier.height(OrganicSpacing.md))
|
||||||
}
|
}
|
||||||
|
item { Spacer(modifier = Modifier.height(24.dp)) }
|
||||||
// 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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
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(24.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -649,11 +593,10 @@ private fun BrowseTabContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun TaskCategorySection(
|
private fun TaskCategorySection(
|
||||||
category: OnboardingTaskCategory,
|
category: OnboardingTaskCategory,
|
||||||
selectedTaskIds: Set<String>,
|
selectedTaskIds: Set<Int>,
|
||||||
isExpanded: Boolean,
|
isExpanded: Boolean,
|
||||||
isAtMaxSelection: Boolean,
|
|
||||||
onToggleExpand: () -> Unit,
|
onToggleExpand: () -> Unit,
|
||||||
onToggleTask: (String) -> Unit
|
onToggleTask: (OnboardingTaskTemplate) -> Unit
|
||||||
) {
|
) {
|
||||||
val selectedInCategory = category.tasks.count { it.id in selectedTaskIds }
|
val selectedInCategory = category.tasks.count { it.id in selectedTaskIds }
|
||||||
|
|
||||||
@@ -662,10 +605,7 @@ private fun TaskCategorySection(
|
|||||||
accentColor = category.color,
|
accentColor = category.color,
|
||||||
showBlob = false
|
showBlob = false
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
// Header
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -673,7 +613,6 @@ private fun TaskCategorySection(
|
|||||||
.padding(OrganicSpacing.md),
|
.padding(OrganicSpacing.md),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Category icon
|
|
||||||
OrganicIconContainer(
|
OrganicIconContainer(
|
||||||
icon = category.icon,
|
icon = category.icon,
|
||||||
size = 44.dp,
|
size = 44.dp,
|
||||||
@@ -691,7 +630,6 @@ private fun TaskCategorySection(
|
|||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Selection badge
|
|
||||||
if (selectedInCategory > 0) {
|
if (selectedInCategory > 0) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -717,25 +655,20 @@ private fun TaskCategorySection(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expanded content
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isExpanded,
|
visible = isExpanded,
|
||||||
enter = fadeIn() + expandVertically(),
|
enter = fadeIn() + expandVertically(),
|
||||||
exit = fadeOut() + shrinkVertically()
|
exit = fadeOut() + shrinkVertically()
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
category.tasks.forEachIndexed { index, task ->
|
category.tasks.forEachIndexed { index, task ->
|
||||||
val isSelected = task.id in selectedTaskIds
|
val isSelected = task.id in selectedTaskIds
|
||||||
val isDisabled = isAtMaxSelection && !isSelected
|
|
||||||
|
|
||||||
TaskTemplateRow(
|
TaskTemplateRow(
|
||||||
task = task,
|
task = task,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
isDisabled = isDisabled,
|
|
||||||
categoryColor = category.color,
|
categoryColor = category.color,
|
||||||
onClick = { onToggleTask(task.id) }
|
onClick = { onToggleTask(task) }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (index < category.tasks.lastIndex) {
|
if (index < category.tasks.lastIndex) {
|
||||||
@@ -755,35 +688,28 @@ private fun TaskCategorySection(
|
|||||||
private fun TaskTemplateRow(
|
private fun TaskTemplateRow(
|
||||||
task: OnboardingTaskTemplate,
|
task: OnboardingTaskTemplate,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
isDisabled: Boolean,
|
|
||||||
categoryColor: Color,
|
categoryColor: Color,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(enabled = !isDisabled) { onClick() }
|
.clickable { onClick() }
|
||||||
.padding(horizontal = OrganicSpacing.md, vertical = OrganicSpacing.sm),
|
.padding(horizontal = OrganicSpacing.md, vertical = OrganicSpacing.sm),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Checkbox
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(28.dp)
|
.size(28.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
if (isSelected) categoryColor
|
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
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Icon(
|
Icon(Icons.Default.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp))
|
||||||
Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,18 +720,12 @@ private fun TaskTemplateRow(
|
|||||||
text = task.title,
|
text = task.title,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = if (isDisabled) {
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = task.frequency.replaceFirstChar { it.uppercase() },
|
text = task.frequencyLabel,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
alpha = if (isDisabled) 0.5f else 1f
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,7 +733,166 @@ private fun TaskTemplateRow(
|
|||||||
imageVector = task.icon,
|
imageVector = task.icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(24.dp),
|
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]
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.tt.honeyDue.data.DataManager
|
import com.tt.honeyDue.data.DataManager
|
||||||
import com.tt.honeyDue.models.AuthResponse
|
import com.tt.honeyDue.models.AuthResponse
|
||||||
|
import com.tt.honeyDue.models.BulkCreateTasksRequest
|
||||||
import com.tt.honeyDue.models.LoginRequest
|
import com.tt.honeyDue.models.LoginRequest
|
||||||
import com.tt.honeyDue.models.RegisterRequest
|
import com.tt.honeyDue.models.RegisterRequest
|
||||||
import com.tt.honeyDue.models.ResidenceCreateRequest
|
import com.tt.honeyDue.models.ResidenceCreateRequest
|
||||||
import com.tt.honeyDue.models.TaskCreateRequest
|
import com.tt.honeyDue.models.TaskCreateRequest
|
||||||
import com.tt.honeyDue.models.TaskSuggestionsResponse
|
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.models.VerifyEmailRequest
|
||||||
import com.tt.honeyDue.network.ApiResult
|
import com.tt.honeyDue.network.ApiResult
|
||||||
import com.tt.honeyDue.network.APILayer
|
import com.tt.honeyDue.network.APILayer
|
||||||
@@ -80,15 +81,17 @@ class OnboardingViewModel : ViewModel() {
|
|||||||
private val _joinResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
private val _joinResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||||
val joinResidenceState: StateFlow<ApiResult<Unit>> = _joinResidenceState
|
val joinResidenceState: StateFlow<ApiResult<Unit>> = _joinResidenceState
|
||||||
|
|
||||||
// Task creation state
|
// Task creation state (bulk create)
|
||||||
private val _createTasksState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
private val _createTasksState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||||
val createTasksState: StateFlow<ApiResult<Unit>> = _createTasksState
|
val createTasksState: StateFlow<ApiResult<Unit>> = _createTasksState
|
||||||
|
|
||||||
// Regional templates state
|
// Grouped templates for the Browse tab on the First-Task screen
|
||||||
private val _regionalTemplates = MutableStateFlow<ApiResult<List<TaskTemplate>>>(ApiResult.Idle)
|
private val _templatesGroupedState = MutableStateFlow<ApiResult<TaskTemplatesGroupedResponse>>(ApiResult.Idle)
|
||||||
val regionalTemplates: StateFlow<ApiResult<List<TaskTemplate>>> = _regionalTemplates
|
val templatesGroupedState: StateFlow<ApiResult<TaskTemplatesGroupedResponse>> = _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("")
|
private val _postalCode = MutableStateFlow("")
|
||||||
val postalCode: StateFlow<String> = _postalCode
|
val postalCode: StateFlow<String> = _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<TaskCreateRequest>) {
|
fun createTasks(residenceId: Int, taskRequests: List<TaskCreateRequest>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (taskRequests.isEmpty()) {
|
if (taskRequests.isEmpty()) {
|
||||||
_createTasksState.value = ApiResult.Success(Unit)
|
_createTasksState.value = ApiResult.Success(Unit)
|
||||||
@@ -407,31 +416,28 @@ class OnboardingViewModel : ViewModel() {
|
|||||||
|
|
||||||
_createTasksState.value = ApiResult.Loading
|
_createTasksState.value = ApiResult.Loading
|
||||||
|
|
||||||
var successCount = 0
|
val request = BulkCreateTasksRequest(
|
||||||
for (request in taskRequests) {
|
residenceId = residenceId,
|
||||||
val result = APILayer.createTask(request)
|
tasks = taskRequests
|
||||||
if (result is ApiResult.Success) {
|
)
|
||||||
successCount++
|
_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
|
||||||
_createTasksState.value = if (successCount > 0) {
|
is ApiResult.Idle -> ApiResult.Idle
|
||||||
ApiResult.Success(Unit)
|
|
||||||
} else {
|
|
||||||
ApiResult.Error("Failed to create tasks")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load regional templates by ZIP code (backend resolves ZIP → state → climate zone).
|
* Load the flat template catalog grouped by category. Feeds the Browse
|
||||||
* Also stores the ZIP code for later use when creating the residence.
|
* tab on the First-Task screen; no caching special-case because
|
||||||
|
* APILayer.getTaskTemplatesGrouped already reads from DataManager first.
|
||||||
*/
|
*/
|
||||||
fun loadRegionalTemplates(zip: String) {
|
fun loadTemplatesGrouped(forceRefresh: Boolean = false) {
|
||||||
_postalCode.value = zip
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_regionalTemplates.value = ApiResult.Loading
|
_templatesGroupedState.value = ApiResult.Loading
|
||||||
_regionalTemplates.value = APILayer.getRegionalTemplates(zip = zip)
|
_templatesGroupedState.value = APILayer.getTaskTemplatesGrouped(forceRefresh = forceRefresh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,7 +462,7 @@ class OnboardingViewModel : ViewModel() {
|
|||||||
_createResidenceState.value = ApiResult.Idle
|
_createResidenceState.value = ApiResult.Idle
|
||||||
_joinResidenceState.value = ApiResult.Idle
|
_joinResidenceState.value = ApiResult.Idle
|
||||||
_createTasksState.value = ApiResult.Idle
|
_createTasksState.value = ApiResult.Idle
|
||||||
_regionalTemplates.value = ApiResult.Idle
|
_templatesGroupedState.value = ApiResult.Idle
|
||||||
_postalCode.value = ""
|
_postalCode.value = ""
|
||||||
_heatingType.value = null
|
_heatingType.value = null
|
||||||
_coolingType.value = null
|
_coolingType.value = null
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ extension DataLayerTests {
|
|||||||
iconAndroid: "",
|
iconAndroid: "",
|
||||||
tags: tags,
|
tags: tags,
|
||||||
displayOrder: 0,
|
displayOrder: 0,
|
||||||
isActive: true,
|
isActive: true
|
||||||
regionId: nil,
|
|
||||||
regionName: nil
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ enum AnalyticsEvent {
|
|||||||
// MARK: - Task
|
// MARK: - Task
|
||||||
case taskCreated(residenceId: Int32)
|
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
|
// MARK: - Contractor
|
||||||
case contractorCreated
|
case contractorCreated
|
||||||
case contractorShared
|
case contractorShared
|
||||||
@@ -64,6 +74,26 @@ enum AnalyticsEvent {
|
|||||||
case .taskCreated(let residenceId):
|
case .taskCreated(let residenceId):
|
||||||
return ("task_created", ["residence_id": 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
|
// Contractor
|
||||||
case .contractorCreated:
|
case .contractorCreated:
|
||||||
return ("contractor_created", nil)
|
return ("contractor_created", nil)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
123
iosApp/iosApp/Onboarding/OnboardingTasksViewModel.swift
Normal file
123
iosApp/iosApp/Onboarding/OnboardingTasksViewModel.swift
Normal file
@@ -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<TaskSuggestionsResponse>,
|
||||||
|
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<TaskTemplatesGroupedResponse>,
|
||||||
|
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<BulkCreateTasksResponse> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -336,6 +336,7 @@ private struct TaskCardBackground: View {
|
|||||||
isCancelled: false,
|
isCancelled: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
parentTaskId: nil,
|
parentTaskId: nil,
|
||||||
|
templateId: nil,
|
||||||
completionCount: 0,
|
completionCount: 0,
|
||||||
kanbanColumn: nil,
|
kanbanColumn: nil,
|
||||||
completions: [],
|
completions: [],
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ struct SwipeHintView: View {
|
|||||||
isCancelled: false,
|
isCancelled: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
parentTaskId: nil,
|
parentTaskId: nil,
|
||||||
|
templateId: nil,
|
||||||
completionCount: 0,
|
completionCount: 0,
|
||||||
kanbanColumn: nil,
|
kanbanColumn: nil,
|
||||||
completions: [],
|
completions: [],
|
||||||
@@ -182,6 +183,7 @@ struct SwipeHintView: View {
|
|||||||
isCancelled: false,
|
isCancelled: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
parentTaskId: nil,
|
parentTaskId: nil,
|
||||||
|
templateId: nil,
|
||||||
completionCount: 3,
|
completionCount: 3,
|
||||||
kanbanColumn: nil,
|
kanbanColumn: nil,
|
||||||
completions: [],
|
completions: [],
|
||||||
|
|||||||
@@ -495,7 +495,8 @@ struct TaskFormView: View {
|
|||||||
assignedToId: nil,
|
assignedToId: nil,
|
||||||
dueDate: dueDateString,
|
dueDate: dueDateString,
|
||||||
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),
|
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
|
viewModel.updateTask(id: task.id, request: request) { success in
|
||||||
@@ -532,7 +533,8 @@ struct TaskFormView: View {
|
|||||||
assignedToId: nil,
|
assignedToId: nil,
|
||||||
dueDate: dueDateString,
|
dueDate: dueDateString,
|
||||||
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),
|
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),
|
||||||
contractorId: nil
|
contractorId: nil,
|
||||||
|
templateId: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.createTask(request: request) { success in
|
viewModel.createTask(request: request) { success in
|
||||||
|
|||||||
Reference in New Issue
Block a user