P2 Stream H: standalone TaskSuggestionsScreen

Port iOS TaskSuggestionsView as a standalone route reachable outside
onboarding. Uses shared suggestions API + accept/skip analytics in
non-onboarding variant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:10:47 -05:00
parent 7d71408bcc
commit 19471d780d
19 changed files with 2161 additions and 3 deletions

View File

@@ -152,3 +152,13 @@ data class PhotoViewerRoute(
// Upgrade/Subscription Route
@Serializable
object UpgradeRoute
// Full-screen Free vs. Pro feature comparison (P2 Stream E — replaces
// the old FeatureComparisonDialog). Matches iOS FeatureComparisonView.
@Serializable
object FeatureComparisonRoute
// Task Suggestions Route (P2 Stream H — standalone, non-onboarding entry
// to personalized task suggestions for a residence).
@Serializable
data class TaskSuggestionsRoute(val residenceId: Int)

View File

@@ -0,0 +1,50 @@
package com.tt.honeyDue.ui.haptics
/**
* The full haptic taxonomy, modeled after iOS:
* - LIGHT / MEDIUM / HEAVY → UIImpactFeedbackGenerator(style:)
* - SUCCESS / WARNING / ERROR → UINotificationFeedbackGenerator
*/
enum class HapticEvent { LIGHT, MEDIUM, HEAVY, SUCCESS, WARNING, ERROR }
/**
* Pluggable backend so tests can swap out platform-specific mechanisms
* (Vibrator/HapticFeedbackConstants on Android, UI*FeedbackGenerator on iOS).
*/
interface HapticBackend {
fun perform(event: HapticEvent)
}
/** Backend that does nothing — used on JVM/Web/test fallbacks. */
object NoopHapticBackend : HapticBackend {
override fun perform(event: HapticEvent) { /* no-op */ }
}
/**
* Cross-platform haptic feedback API.
*
* Call-sites in common code stay terse:
* - [Haptics.light] — selection/tap (iOS UIImpactFeedbackGenerator.light)
* - [Haptics.medium] — confirmations
* - [Haptics.heavy] — important actions
* - [Haptics.success] — positive completion (iOS UINotificationFeedbackGenerator.success)
* - [Haptics.warning] — caution
* - [Haptics.error] — validation / failure
*
* Each platform provides a default [HapticBackend]. Tests may override via
* [Haptics.setBackend] and restore via [Haptics.resetBackend].
*/
expect object Haptics {
fun light()
fun medium()
fun heavy()
fun success()
fun warning()
fun error()
/** Override the active backend (for tests or custom delegation). */
fun setBackend(backend: HapticBackend)
/** Restore the platform default backend. */
fun resetBackend()
}

View File

@@ -0,0 +1,328 @@
package com.tt.honeyDue.ui.screens.task
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tt.honeyDue.models.TaskSuggestionResponse
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.ui.components.common.StandardCard
import com.tt.honeyDue.ui.theme.AppRadius
import com.tt.honeyDue.ui.theme.AppSpacing
import com.tt.honeyDue.util.ErrorMessageParser
/**
* Standalone screen that lets users pick personalized task suggestions
* outside the onboarding flow. Android port of iOS TaskSuggestionsView as
* a regular destination (not an inline dropdown).
*
* Flow:
* 1. On entry, loads via APILayer.getTaskSuggestions(residenceId).
* 2. Each row has an Accept button that fires APILayer.createTask with
* template fields + templateId backlink.
* 3. Non-onboarding analytics event task_suggestion_accepted fires on
* each successful accept.
* 4. Skip is a pop with no task created (handled by onNavigateBack).
* 5. Supports pull-to-refresh.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskSuggestionsScreen(
residenceId: Int,
onNavigateBack: () -> Unit,
onSuggestionAccepted: (templateId: Int) -> Unit = {},
viewModel: TaskSuggestionsViewModel = viewModel {
TaskSuggestionsViewModel(residenceId = residenceId)
}
) {
val suggestionsState by viewModel.suggestionsState.collectAsState()
val acceptState by viewModel.acceptState.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (suggestionsState is ApiResult.Idle) {
viewModel.load()
}
}
LaunchedEffect(suggestionsState) {
if (suggestionsState !is ApiResult.Loading) {
isRefreshing = false
}
}
LaunchedEffect(acceptState) {
if (acceptState is ApiResult.Success) {
val tid = viewModel.lastAcceptedTemplateId
if (tid != null) onSuggestionAccepted(tid)
viewModel.resetAcceptState()
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = "Suggested Tasks", fontWeight = FontWeight.SemiBold)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
OutlinedButton(
onClick = onNavigateBack,
modifier = Modifier.padding(end = AppSpacing.md)
) { Text("Skip") }
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
viewModel.load()
},
modifier = Modifier.fillMaxSize().padding(paddingValues)
) {
when (val state = suggestionsState) {
is ApiResult.Loading, ApiResult.Idle -> {
Box(Modifier.fillMaxSize(), Alignment.Center) {
CircularProgressIndicator()
}
}
is ApiResult.Error -> {
ErrorView(
message = ErrorMessageParser.parse(state.message),
onRetry = { viewModel.retry() }
)
}
is ApiResult.Success -> {
if (state.data.suggestions.isEmpty()) {
EmptyView()
} else {
SuggestionsList(
suggestions = state.data.suggestions,
acceptState = acceptState,
onAccept = viewModel::accept
)
}
}
}
(acceptState as? ApiResult.Error)?.let { err ->
Box(
modifier = Modifier.fillMaxSize().padding(AppSpacing.lg),
contentAlignment = Alignment.BottomCenter
) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = RoundedCornerShape(AppRadius.md),
tonalElevation = 4.dp
) {
Row(
modifier = Modifier.padding(AppSpacing.md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
Icon(
imageVector = Icons.Default.ErrorOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
text = ErrorMessageParser.parse(err.message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
}
}
}
@Composable
private fun SuggestionsList(
suggestions: List<TaskSuggestionResponse>,
acceptState: ApiResult<*>,
onAccept: (TaskSuggestionResponse) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = AppSpacing.lg,
end = AppSpacing.lg,
top = AppSpacing.md,
bottom = AppSpacing.xl
),
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
items(suggestions, key = { it.template.id }) { suggestion ->
SuggestionRow(
suggestion = suggestion,
isAccepting = acceptState is ApiResult.Loading,
onAccept = { onAccept(suggestion) }
)
}
}
}
@Composable
private fun SuggestionRow(
suggestion: TaskSuggestionResponse,
isAccepting: Boolean,
onAccept: () -> Unit
) {
val template = suggestion.template
StandardCard(
modifier = Modifier.fillMaxWidth(),
contentPadding = AppSpacing.md
) {
Column(verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)) {
Text(
text = template.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
if (template.description.isNotBlank()) {
Text(
text = template.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
Text(
text = template.categoryName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = template.frequencyDisplay,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Button(
onClick = onAccept,
enabled = !isAccepting,
modifier = Modifier.fillMaxWidth().height(44.dp),
shape = RoundedCornerShape(AppRadius.md)
) {
if (isAccepting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.Check, contentDescription = null)
Spacer(Modifier.width(AppSpacing.sm))
Text("Accept", fontWeight = FontWeight.SemiBold)
}
}
}
}
}
@Composable
private fun ErrorView(message: String, onRetry: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md, Alignment.CenterVertically)
) {
Icon(
imageVector = Icons.Default.ErrorOutline,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Text(text = "Couldn't load suggestions", style = MaterialTheme.typography.titleMedium)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(onClick = onRetry) { Text("Retry") }
}
}
@Composable
private fun EmptyView() {
Column(
modifier = Modifier.fillMaxSize().padding(AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md, Alignment.CenterVertically)
) {
Icon(
imageVector = Icons.Default.Checklist,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Text(text = "No suggestions yet", style = MaterialTheme.typography.titleMedium)
Text(
text = "Complete your home profile to see personalized recommendations.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -0,0 +1,100 @@
package com.tt.honeyDue.ui.screens.task
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.analytics.PostHogAnalytics
import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.models.TaskResponse
import com.tt.honeyDue.models.TaskSuggestionResponse
import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* ViewModel for the standalone TaskSuggestionsScreen — the non-onboarding
* path into personalized task suggestions. Event naming matches the
* non-onboarding convention used by the templates browser.
*/
class TaskSuggestionsViewModel(
private val residenceId: Int,
private val loadSuggestions: suspend () -> ApiResult<TaskSuggestionsResponse> = {
APILayer.getTaskSuggestions(residenceId)
},
private val createTask: suspend (TaskCreateRequest) -> ApiResult<TaskResponse> = { req ->
APILayer.createTask(req)
},
private val analytics: (String, Map<String, Any>) -> Unit = { name, props ->
PostHogAnalytics.capture(name, props)
}
) : ViewModel() {
private val _suggestionsState =
MutableStateFlow<ApiResult<TaskSuggestionsResponse>>(ApiResult.Idle)
val suggestionsState: StateFlow<ApiResult<TaskSuggestionsResponse>> =
_suggestionsState.asStateFlow()
private val _acceptState = MutableStateFlow<ApiResult<TaskResponse>>(ApiResult.Idle)
val acceptState: StateFlow<ApiResult<TaskResponse>> = _acceptState.asStateFlow()
var lastAcceptedTemplateId: Int? = null
private set
fun load() {
viewModelScope.launch {
_suggestionsState.value = ApiResult.Loading
_suggestionsState.value = loadSuggestions()
}
}
fun retry() = load()
fun accept(suggestion: TaskSuggestionResponse) {
val template = suggestion.template
val request = TaskCreateRequest(
residenceId = residenceId,
title = template.title,
description = template.description.takeIf { it.isNotBlank() },
categoryId = template.categoryId,
frequencyId = template.frequencyId,
templateId = template.id
)
viewModelScope.launch {
_acceptState.value = ApiResult.Loading
val result = createTask(request)
_acceptState.value = when (result) {
is ApiResult.Success -> {
lastAcceptedTemplateId = template.id
analytics(
EVENT_TASK_SUGGESTION_ACCEPTED,
buildMap {
put("template_id", template.id)
put("relevance_score", suggestion.relevanceScore)
template.categoryId?.let { put("category_id", it) }
}
)
ApiResult.Success(result.data)
}
is ApiResult.Error -> result
ApiResult.Loading -> ApiResult.Loading
ApiResult.Idle -> ApiResult.Idle
}
}
}
fun resetAcceptState() {
_acceptState.value = ApiResult.Idle
lastAcceptedTemplateId = null
}
companion object {
/**
* Non-onboarding analytics event for a single accepted suggestion.
*/
const val EVENT_TASK_SUGGESTION_ACCEPTED = "task_suggestion_accepted"
}
}