P7 Stream Y: empty/error/loading state audit (iOS parity)

Audits every list + detail screen (non-document) for empty/error/loading
state parity with iOS. Reuses StandardEmptyState / StandardErrorState where
possible; adds missing states where screens currently show blank on error.

- Add StandardErrorState and CompactErrorState components under
  ui/components/common/ (mirrors iOS ErrorView pattern: icon + title +
  message + Retry).
- ManageUsersScreen: error state previously had no retry button; now uses
  StandardErrorState with a Retry CTA matching iOS ManageUsersView.
- ResidenceDetailScreen: task and contractor sub-section error cards now
  use CompactErrorState with inline retry (previously plain error text).

Other audited screens (ResidencesScreen, TasksScreen, AllTasksScreen,
ContractorsScreen, ContractorDetailScreen, EditTaskScreen,
CompleteTaskScreen, TaskTemplatesBrowserScreen, TaskSuggestionsScreen,
OnboardingFirstTaskContent) already had loading + error + empty parity
via ApiResultHandler / HandleErrors / inline state machines; no changes
needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 14:50:07 -05:00
parent d42406cbec
commit 840c35a7af
3 changed files with 145 additions and 46 deletions

View File

@@ -0,0 +1,120 @@
package com.tt.honeyDue.ui.components.common
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tt.honeyDue.ui.theme.AppSpacing
/**
* StandardErrorState - Inline error state with retry button
* Matches iOS ErrorView pattern
*
* Use this for inline error states where the caller wants a retry CTA
* directly in the content area (e.g. list/detail load failure).
* For modal error presentation, keep using ErrorDialog / HandleErrors.
*
* Usage:
* ```
* StandardErrorState(
* title = "Couldn't load users",
* message = errorMessage,
* onRetry = { viewModel.refresh() }
* )
* ```
*/
@Composable
fun StandardErrorState(
message: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
title: String = "Something went wrong",
icon: ImageVector = Icons.Default.ErrorOutline,
retryLabel: String = "Retry"
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md, Alignment.CenterVertically)
) {
Icon(
imageVector = icon,
contentDescription = null, // decorative
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 320.dp)
)
OutlinedButton(onClick = onRetry) {
Text(retryLabel)
}
}
}
/**
* Compact inline error for embedded sub-sections (e.g. inside a LazyColumn item)
* where we don't have fillMaxSize available. Shows the message + inline retry.
*/
@Composable
fun CompactErrorState(
message: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
retryLabel: String = "Retry"
) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
Icon(
imageVector = Icons.Default.ErrorOutline,
contentDescription = null, // decorative
tint = MaterialTheme.colorScheme.onErrorContainer
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f)
)
}
TextButton(
onClick = onRetry,
modifier = Modifier.align(Alignment.End)
) {
Text(retryLabel)
}
}
}
}

View File

@@ -25,6 +25,7 @@ import com.tt.honeyDue.models.ResidenceShareCode
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.testing.AccessibilityIds
import com.tt.honeyDue.ui.components.common.StandardErrorState
import com.tt.honeyDue.ui.theme.*
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@@ -51,8 +52,10 @@ fun ManageUsersScreen(
val clipboardManager = LocalClipboardManager.current
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(residenceId) {
shareCode = null
// Extracted so retry can reuse it.
suspend fun loadUsers() {
isLoading = true
error = null
when (val result = APILayer.getResidenceUsers(residenceId)) {
is ApiResult.Success -> {
users = result.data
@@ -66,6 +69,11 @@ fun ManageUsersScreen(
}
}
LaunchedEffect(residenceId) {
shareCode = null
loadUsers()
}
WarmGradientBackground {
Scaffold(
modifier = Modifier.semantics { testTagsAsResourceId = true },
@@ -107,28 +115,12 @@ fun ManageUsersScreen(
CircularProgressIndicator()
}
} else if (error != null) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Icon(
Icons.Default.Error,
contentDescription = null, // decorative
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Text(
text = error ?: "Unknown error",
color = MaterialTheme.colorScheme.error
)
}
}
StandardErrorState(
title = "Couldn't load users",
message = error ?: "Unknown error",
onRetry = { scope.launch { loadUsers() } },
modifier = Modifier.padding(paddingValues)
)
} else {
LazyColumn(
modifier = Modifier

View File

@@ -25,6 +25,7 @@ import com.tt.honeyDue.ui.components.ApiResultHandler
import com.tt.honeyDue.ui.components.CompleteTaskDialog
import com.tt.honeyDue.ui.components.HandleErrors
import com.tt.honeyDue.ui.components.ManageUsersDialog
import com.tt.honeyDue.ui.components.common.CompactErrorState
import com.tt.honeyDue.ui.components.common.InfoCard
import com.tt.honeyDue.ui.components.residence.PropertyDetailItem
import com.tt.honeyDue.ui.components.residence.DetailRow
@@ -874,17 +875,10 @@ fun ResidenceDetailScreen(
}
is ApiResult.Error -> {
item {
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.error,
showBlob = false
) {
Text(
text = "Error loading tasks: ${com.tt.honeyDue.util.ErrorMessageParser.parse((tasksState as ApiResult.Error).message)}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(OrganicSpacing.cozy)
)
}
CompactErrorState(
message = "Error loading tasks: ${com.tt.honeyDue.util.ErrorMessageParser.parse((tasksState as ApiResult.Error).message)}",
onRetry = { residenceViewModel.loadResidenceTasks(residenceId) }
)
}
}
is ApiResult.Success -> {
@@ -1016,17 +1010,10 @@ fun ResidenceDetailScreen(
}
is ApiResult.Error -> {
item {
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.error,
showBlob = false
) {
Text(
text = "Error loading contractors: ${com.tt.honeyDue.util.ErrorMessageParser.parse((contractorsState as ApiResult.Error).message)}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(OrganicSpacing.cozy)
)
}
CompactErrorState(
message = "Error loading contractors: ${com.tt.honeyDue.util.ErrorMessageParser.parse((contractorsState as ApiResult.Error).message)}",
onRetry = { residenceViewModel.loadResidenceContractors(residenceId) }
)
}
}
is ApiResult.Success -> {