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.ApiResult
|
||||||
import com.tt.honeyDue.network.APILayer
|
import com.tt.honeyDue.network.APILayer
|
||||||
import com.tt.honeyDue.testing.AccessibilityIds
|
import com.tt.honeyDue.testing.AccessibilityIds
|
||||||
|
import com.tt.honeyDue.ui.components.common.StandardErrorState
|
||||||
import com.tt.honeyDue.ui.theme.*
|
import com.tt.honeyDue.ui.theme.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
@@ -51,8 +52,10 @@ fun ManageUsersScreen(
|
|||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
LaunchedEffect(residenceId) {
|
// Extracted so retry can reuse it.
|
||||||
shareCode = null
|
suspend fun loadUsers() {
|
||||||
|
isLoading = true
|
||||||
|
error = null
|
||||||
when (val result = APILayer.getResidenceUsers(residenceId)) {
|
when (val result = APILayer.getResidenceUsers(residenceId)) {
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
users = result.data
|
users = result.data
|
||||||
@@ -66,6 +69,11 @@ fun ManageUsersScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(residenceId) {
|
||||||
|
shareCode = null
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
WarmGradientBackground {
|
WarmGradientBackground {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.semantics { testTagsAsResourceId = true },
|
modifier = Modifier.semantics { testTagsAsResourceId = true },
|
||||||
@@ -107,28 +115,12 @@ fun ManageUsersScreen(
|
|||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
} else if (error != null) {
|
} else if (error != null) {
|
||||||
Box(
|
StandardErrorState(
|
||||||
modifier = Modifier
|
title = "Couldn't load users",
|
||||||
.fillMaxSize()
|
message = error ?: "Unknown error",
|
||||||
.padding(paddingValues),
|
onRetry = { scope.launch { loadUsers() } },
|
||||||
contentAlignment = Alignment.Center
|
modifier = Modifier.padding(paddingValues)
|
||||||
) {
|
)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
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.CompleteTaskDialog
|
||||||
import com.tt.honeyDue.ui.components.HandleErrors
|
import com.tt.honeyDue.ui.components.HandleErrors
|
||||||
import com.tt.honeyDue.ui.components.ManageUsersDialog
|
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.common.InfoCard
|
||||||
import com.tt.honeyDue.ui.components.residence.PropertyDetailItem
|
import com.tt.honeyDue.ui.components.residence.PropertyDetailItem
|
||||||
import com.tt.honeyDue.ui.components.residence.DetailRow
|
import com.tt.honeyDue.ui.components.residence.DetailRow
|
||||||
@@ -874,17 +875,10 @@ fun ResidenceDetailScreen(
|
|||||||
}
|
}
|
||||||
is ApiResult.Error -> {
|
is ApiResult.Error -> {
|
||||||
item {
|
item {
|
||||||
OrganicCard(
|
CompactErrorState(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
message = "Error loading tasks: ${com.tt.honeyDue.util.ErrorMessageParser.parse((tasksState as ApiResult.Error).message)}",
|
||||||
accentColor = MaterialTheme.colorScheme.error,
|
onRetry = { residenceViewModel.loadResidenceTasks(residenceId) }
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
@@ -1016,17 +1010,10 @@ fun ResidenceDetailScreen(
|
|||||||
}
|
}
|
||||||
is ApiResult.Error -> {
|
is ApiResult.Error -> {
|
||||||
item {
|
item {
|
||||||
OrganicCard(
|
CompactErrorState(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
message = "Error loading contractors: ${com.tt.honeyDue.util.ErrorMessageParser.parse((contractorsState as ApiResult.Error).message)}",
|
||||||
accentColor = MaterialTheme.colorScheme.error,
|
onRetry = { residenceViewModel.loadResidenceContractors(residenceId) }
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
|
|||||||
Reference in New Issue
Block a user