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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
Reference in New Issue
Block a user