Add comprehensive error message parsing to prevent raw JSON display
- Created ErrorMessageParser utility for both iOS (Swift) and Android (Kotlin) - Parser detects JSON-formatted error messages and extracts user-friendly text - Identifies when data objects (not errors) are returned and provides generic messages - Updated all API error handling to pass raw error bodies instead of concatenating - Applied ErrorMessageParser across all ViewModels and screens on both platforms - Fixed ContractorApi and DocumentApi to not concatenate error bodies with messages - Updated ApiResultHandler to automatically parse all error messages - Error messages now show "Request failed. Please check your input and try again." instead of raw JSON 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -62,13 +62,12 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
val errorMessage = try {
|
||||
val errorBody: String = response.body()
|
||||
"Failed to create contractor: $errorBody"
|
||||
val errorBody = try {
|
||||
response.body<String>()
|
||||
} catch (e: Exception) {
|
||||
"Failed to create contractor"
|
||||
}
|
||||
ApiResult.Error(errorMessage, response.status.value)
|
||||
ApiResult.Error(errorBody, response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
@@ -86,13 +85,12 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
val errorMessage = try {
|
||||
val errorBody: String = response.body()
|
||||
"Failed to update contractor: $errorBody"
|
||||
val errorBody = try {
|
||||
response.body<String>()
|
||||
} catch (e: Exception) {
|
||||
"Failed to update contractor"
|
||||
}
|
||||
ApiResult.Error(errorMessage, response.status.value)
|
||||
ApiResult.Error(errorBody, response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
|
||||
@@ -175,13 +175,12 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
val errorMessage = try {
|
||||
val errorBody: String = response.body()
|
||||
"Failed to create document: $errorBody"
|
||||
val errorBody = try {
|
||||
response.body<String>()
|
||||
} catch (e: Exception) {
|
||||
"Failed to create document"
|
||||
}
|
||||
ApiResult.Error(errorMessage, response.status.value)
|
||||
ApiResult.Error(errorBody, response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
@@ -284,13 +283,12 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
val errorMessage = try {
|
||||
val errorBody: String = response.body()
|
||||
"Failed to update document: $errorBody"
|
||||
val errorBody = try {
|
||||
response.body<String>()
|
||||
} catch (e: Exception) {
|
||||
"Failed to update document"
|
||||
}
|
||||
ApiResult.Error(errorMessage, response.status.value)
|
||||
ApiResult.Error(errorBody, response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
@@ -403,13 +401,12 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
val errorMessage = try {
|
||||
val errorBody: String = response.body()
|
||||
"Failed to upload image: $errorBody"
|
||||
val errorBody = try {
|
||||
response.body<String>()
|
||||
} catch (e: Exception) {
|
||||
"Failed to upload image"
|
||||
}
|
||||
ApiResult.Error(errorMessage, response.status.value)
|
||||
ApiResult.Error(errorBody, response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
|
||||
@@ -125,7 +125,7 @@ fun <T> ApiResult<T>.HandleErrors(
|
||||
|
||||
LaunchedEffect(this) {
|
||||
if (this@HandleErrors is ApiResult.Error) {
|
||||
errorMessage = (this@HandleErrors as ApiResult.Error).message
|
||||
errorMessage = com.mycrib.android.util.ErrorMessageParser.parse((this@HandleErrors as ApiResult.Error).message)
|
||||
showErrorDialog = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.ui.components.AddNewTaskWithResidenceDialog
|
||||
import com.mycrib.android.ui.components.ApiResultHandler
|
||||
import com.mycrib.android.ui.components.CompleteTaskDialog
|
||||
import com.mycrib.android.ui.components.HandleErrors
|
||||
import com.mycrib.android.ui.components.task.TaskCard
|
||||
import com.mycrib.android.ui.components.task.DynamicTaskKanbanView
|
||||
import com.mycrib.android.viewmodel.ResidenceViewModel
|
||||
@@ -59,6 +61,12 @@ fun AllTasksScreen(
|
||||
}
|
||||
|
||||
// Handle task creation success
|
||||
// Handle errors for task creation
|
||||
createTaskState.HandleErrors(
|
||||
onRetry = { /* Retry handled in dialog */ },
|
||||
errorTitle = "Failed to Create Task"
|
||||
)
|
||||
|
||||
LaunchedEffect(createTaskState) {
|
||||
println("AllTasksScreen: createTaskState changed to $createTaskState")
|
||||
when (createTaskState) {
|
||||
@@ -68,15 +76,7 @@ fun AllTasksScreen(
|
||||
viewModel.resetAddTaskState()
|
||||
viewModel.loadTasks()
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
println("AllTasksScreen: Task creation error: ${(createTaskState as ApiResult.Error).message}")
|
||||
}
|
||||
is ApiResult.Loading -> {
|
||||
println("AllTasksScreen: Task creation loading")
|
||||
}
|
||||
else -> {
|
||||
println("AllTasksScreen: Task creation idle")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,48 +115,12 @@ fun AllTasksScreen(
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
when (tasksState) {
|
||||
is ApiResult.Idle, is ApiResult.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = "Error: ${(tasksState as ApiResult.Error).message}",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(onClick = { viewModel.loadTasks(forceRefresh = true) }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is ApiResult.Success -> {
|
||||
val taskData = (tasksState as ApiResult.Success).data
|
||||
ApiResultHandler(
|
||||
state = tasksState,
|
||||
onRetry = { viewModel.loadTasks(forceRefresh = true) },
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
errorTitle = "Failed to Load Tasks"
|
||||
) { taskData ->
|
||||
val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
|
||||
|
||||
if (hasNoTasks) {
|
||||
@@ -263,10 +227,7 @@ fun AllTasksScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCompleteDialog && selectedTask != null) {
|
||||
CompleteTaskDialog(
|
||||
@@ -303,7 +264,7 @@ fun AllTasksScreen(
|
||||
},
|
||||
isLoading = createTaskState is ApiResult.Loading,
|
||||
errorMessage = if (createTaskState is ApiResult.Error) {
|
||||
(createTaskState as ApiResult.Error).message
|
||||
com.mycrib.android.util.ErrorMessageParser.parse((createTaskState as ApiResult.Error).message)
|
||||
} else null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.ui.components.AddContractorDialog
|
||||
import com.mycrib.android.ui.components.ApiResultHandler
|
||||
import com.mycrib.android.ui.components.HandleErrors
|
||||
import com.mycrib.android.viewmodel.ContractorViewModel
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
|
||||
@@ -39,6 +41,18 @@ fun ContractorDetailScreen(
|
||||
viewModel.loadContractorDetail(contractorId)
|
||||
}
|
||||
|
||||
// Handle errors for delete contractor
|
||||
deleteState.HandleErrors(
|
||||
onRetry = { viewModel.deleteContractor(contractorId) },
|
||||
errorTitle = "Failed to Delete Contractor"
|
||||
)
|
||||
|
||||
// Handle errors for toggle favorite
|
||||
toggleFavoriteState.HandleErrors(
|
||||
onRetry = { viewModel.toggleFavorite(contractorId) },
|
||||
errorTitle = "Failed to Update Favorite"
|
||||
)
|
||||
|
||||
LaunchedEffect(deleteState) {
|
||||
if (deleteState is ApiResult.Success) {
|
||||
viewModel.resetDeleteState()
|
||||
@@ -94,18 +108,14 @@ fun ContractorDetailScreen(
|
||||
.padding(padding)
|
||||
.background(Color(0xFFF9FAFB))
|
||||
) {
|
||||
when (val state = contractorState) {
|
||||
is ApiResult.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = Color(0xFF2563EB))
|
||||
}
|
||||
ApiResultHandler(
|
||||
state = contractorState,
|
||||
onRetry = { viewModel.loadContractorDetail(contractorId) },
|
||||
errorTitle = "Failed to Load Contractor",
|
||||
loadingContent = {
|
||||
CircularProgressIndicator(color = Color(0xFF2563EB))
|
||||
}
|
||||
is ApiResult.Success -> {
|
||||
val contractor = state.data
|
||||
|
||||
) { contractor ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
@@ -346,35 +356,8 @@ fun ContractorDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ErrorOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = Color(0xFFEF4444)
|
||||
)
|
||||
Text(state.message, color = Color(0xFFEF4444))
|
||||
Button(
|
||||
onClick = { viewModel.loadContractorDetail(contractorId) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2563EB))
|
||||
) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showEditDialog) {
|
||||
AddContractorDialog(
|
||||
|
||||
@@ -21,6 +21,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.ui.components.AddContractorDialog
|
||||
import com.mycrib.android.ui.components.ApiResultHandler
|
||||
import com.mycrib.android.ui.components.HandleErrors
|
||||
import com.mycrib.android.viewmodel.ContractorViewModel
|
||||
import com.mycrib.shared.models.ContractorSummary
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
@@ -64,6 +66,18 @@ fun ContractorsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Handle errors for delete contractor
|
||||
deleteState.HandleErrors(
|
||||
onRetry = { /* Handled in UI */ },
|
||||
errorTitle = "Failed to Delete Contractor"
|
||||
)
|
||||
|
||||
// Handle errors for toggle favorite
|
||||
toggleFavoriteState.HandleErrors(
|
||||
onRetry = { /* Handled in UI */ },
|
||||
errorTitle = "Failed to Update Favorite"
|
||||
)
|
||||
|
||||
LaunchedEffect(deleteState) {
|
||||
if (deleteState is ApiResult.Success) {
|
||||
viewModel.loadContractors()
|
||||
@@ -216,81 +230,25 @@ fun ContractorsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
when (val state = contractorsState) {
|
||||
is ApiResult.Loading -> {
|
||||
ApiResultHandler(
|
||||
state = contractorsState,
|
||||
onRetry = {
|
||||
viewModel.loadContractors(
|
||||
specialty = selectedFilter,
|
||||
isFavorite = if (showFavoritesOnly) true else null,
|
||||
search = searchQuery.takeIf { it.isNotBlank() }
|
||||
)
|
||||
},
|
||||
errorTitle = "Failed to Load Contractors",
|
||||
loadingContent = {
|
||||
if (!isRefreshing) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
is ApiResult.Success -> {
|
||||
val contractors = state.data.results
|
||||
) { state ->
|
||||
val contractors = state.results
|
||||
|
||||
if (contractors.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
if (searchQuery.isNotEmpty() || selectedFilter != null || showFavoritesOnly)
|
||||
"No contractors found"
|
||||
else
|
||||
"No contractors yet",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (searchQuery.isEmpty() && selectedFilter == null && !showFavoritesOnly) {
|
||||
Text(
|
||||
"Add your first contractor to get started",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
viewModel.loadContractors(
|
||||
specialty = selectedFilter,
|
||||
isFavorite = if (showFavoritesOnly) true else null,
|
||||
search = searchQuery.takeIf { it.isNotBlank() }
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(contractors, key = { it.id }) { contractor ->
|
||||
ContractorCard(
|
||||
contractor = contractor,
|
||||
onToggleFavorite = { viewModel.toggleFavorite(it) },
|
||||
onClick = { onNavigateToContractorDetail(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
if (contractors.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -300,24 +258,55 @@ fun ContractorsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ErrorOutline,
|
||||
Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(state.message, color = MaterialTheme.colorScheme.error)
|
||||
Button(
|
||||
onClick = { viewModel.loadContractors() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
Text(
|
||||
if (searchQuery.isNotEmpty() || selectedFilter != null || showFavoritesOnly)
|
||||
"No contractors found"
|
||||
else
|
||||
"No contractors yet",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (searchQuery.isEmpty() && selectedFilter == null && !showFavoritesOnly) {
|
||||
Text(
|
||||
"Add your first contractor to get started",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
viewModel.loadContractors(
|
||||
specialty = selectedFilter,
|
||||
isFavorite = if (showFavoritesOnly) true else null,
|
||||
search = searchQuery.takeIf { it.isNotBlank() }
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(contractors, key = { it.id }) { contractor ->
|
||||
ContractorCard(
|
||||
contractor = contractor,
|
||||
onToggleFavorite = { viewModel.toggleFavorite(it) },
|
||||
onClick = { onNavigateToContractorDetail(it) }
|
||||
)
|
||||
) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.ui.components.ApiResultHandler
|
||||
import com.mycrib.android.ui.components.HandleErrors
|
||||
import com.mycrib.android.viewmodel.DocumentViewModel
|
||||
import com.mycrib.shared.models.*
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
@@ -51,6 +53,12 @@ fun DocumentDetailScreen(
|
||||
documentViewModel.loadDocumentDetail(documentId)
|
||||
}
|
||||
|
||||
// Handle errors for document deletion
|
||||
deleteState.HandleErrors(
|
||||
onRetry = { documentViewModel.deleteDocument(documentId) },
|
||||
errorTitle = "Failed to Delete Document"
|
||||
)
|
||||
|
||||
// Handle successful deletion
|
||||
LaunchedEffect(deleteState) {
|
||||
if (deleteState is ApiResult.Success) {
|
||||
@@ -89,17 +97,11 @@ fun DocumentDetailScreen(
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
when (val state = documentState) {
|
||||
is ApiResult.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is ApiResult.Success -> {
|
||||
val document = state.data
|
||||
ApiResultHandler(
|
||||
state = documentState,
|
||||
onRetry = { documentViewModel.loadDocumentDetail(documentId) },
|
||||
errorTitle = "Failed to Load Document"
|
||||
) { document ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -409,16 +411,8 @@ fun DocumentDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
ErrorState(
|
||||
message = state.message,
|
||||
onRetry = { documentViewModel.loadDocumentDetail(documentId) }
|
||||
)
|
||||
}
|
||||
is ApiResult.Idle -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
if (showDeleteDialog) {
|
||||
|
||||
@@ -239,7 +239,7 @@ fun DocumentFormScreen(
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Text(
|
||||
"Failed to load residences: ${(residencesState as ApiResult.Error).message}",
|
||||
"Failed to load residences: ${com.mycrib.android.util.ErrorMessageParser.parse((residencesState as ApiResult.Error).message)}",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
@@ -596,7 +596,7 @@ fun DocumentFormScreen(
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
(operationState as ApiResult.Error).message,
|
||||
com.mycrib.android.util.ErrorMessageParser.parse((operationState as ApiResult.Error).message),
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.ui.components.HandleErrors
|
||||
import com.mycrib.android.viewmodel.ResidenceViewModel
|
||||
import com.mycrib.repository.LookupsRepository
|
||||
import com.mycrib.shared.models.*
|
||||
@@ -49,6 +50,12 @@ fun EditTaskScreen(
|
||||
var titleError by remember { mutableStateOf("") }
|
||||
var dueDateError by remember { mutableStateOf("") }
|
||||
|
||||
// Handle errors for task update
|
||||
updateTaskState.HandleErrors(
|
||||
onRetry = { /* Retry handled in UI */ },
|
||||
errorTitle = "Failed to Update Task"
|
||||
)
|
||||
|
||||
// Handle update state changes
|
||||
LaunchedEffect(updateTaskState) {
|
||||
when (updateTaskState) {
|
||||
@@ -279,7 +286,7 @@ fun EditTaskScreen(
|
||||
// Error message
|
||||
if (updateTaskState is ApiResult.Error) {
|
||||
Text(
|
||||
text = (updateTaskState as ApiResult.Error).message,
|
||||
text = com.mycrib.android.util.ErrorMessageParser.parse((updateTaskState as ApiResult.Error).message),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.ui.components.HandleErrors
|
||||
import com.mycrib.android.ui.components.auth.AuthHeader
|
||||
import com.mycrib.android.ui.components.common.ErrorCard
|
||||
import com.mycrib.android.viewmodel.PasswordResetViewModel
|
||||
@@ -30,6 +31,15 @@ fun ForgotPasswordScreen(
|
||||
val forgotPasswordState by viewModel.forgotPasswordState.collectAsState()
|
||||
val currentStep by viewModel.currentStep.collectAsState()
|
||||
|
||||
// Handle errors for forgot password
|
||||
forgotPasswordState.HandleErrors(
|
||||
onRetry = {
|
||||
viewModel.setEmail(email)
|
||||
viewModel.requestPasswordReset(email)
|
||||
},
|
||||
errorTitle = "Failed to Send Reset Code"
|
||||
)
|
||||
|
||||
// Handle automatic navigation to next step
|
||||
LaunchedEffect(currentStep) {
|
||||
when (currentStep) {
|
||||
@@ -40,7 +50,7 @@ fun ForgotPasswordScreen(
|
||||
}
|
||||
|
||||
val errorMessage = when (forgotPasswordState) {
|
||||
is ApiResult.Error -> (forgotPasswordState as ApiResult.Error).message
|
||||
is ApiResult.Error -> com.mycrib.android.util.ErrorMessageParser.parse((forgotPasswordState as ApiResult.Error).message)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.ui.components.HandleErrors
|
||||
import com.mycrib.android.ui.theme.AppRadius
|
||||
import com.mycrib.android.viewmodel.ResidenceViewModel
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
@@ -33,6 +34,12 @@ fun HomeScreen(
|
||||
viewModel.loadResidenceSummary()
|
||||
}
|
||||
|
||||
// Handle errors for loading summary
|
||||
summaryState.HandleErrors(
|
||||
onRetry = { viewModel.loadResidenceSummary() },
|
||||
errorTitle = "Failed to Load Summary"
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.ui.components.HandleErrors
|
||||
import com.mycrib.android.ui.components.auth.AuthHeader
|
||||
import com.mycrib.android.ui.components.common.ErrorCard
|
||||
import com.mycrib.android.viewmodel.AuthViewModel
|
||||
@@ -38,6 +39,12 @@ fun LoginScreen(
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
val loginState by viewModel.loginState.collectAsState()
|
||||
|
||||
// Handle errors for login
|
||||
loginState.HandleErrors(
|
||||
onRetry = { viewModel.login(username, password) },
|
||||
errorTitle = "Login Failed"
|
||||
)
|
||||
|
||||
// Handle login state changes
|
||||
LaunchedEffect(loginState) {
|
||||
when (loginState) {
|
||||
@@ -50,7 +57,7 @@ fun LoginScreen(
|
||||
}
|
||||
|
||||
val errorMessage = when (loginState) {
|
||||
is ApiResult.Error -> (loginState as ApiResult.Error).message
|
||||
is ApiResult.Error -> com.mycrib.android.util.ErrorMessageParser.parse((loginState as ApiResult.Error).message)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.ui.components.HandleErrors
|
||||
import com.mycrib.android.ui.components.common.ErrorCard
|
||||
import com.mycrib.android.viewmodel.AuthViewModel
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
@@ -36,6 +37,18 @@ fun ProfileScreen(
|
||||
|
||||
val updateState by viewModel.updateProfileState.collectAsState()
|
||||
|
||||
// Handle errors for profile update
|
||||
updateState.HandleErrors(
|
||||
onRetry = {
|
||||
viewModel.updateProfile(
|
||||
firstName = firstName.ifBlank { null },
|
||||
lastName = lastName.ifBlank { null },
|
||||
email = email
|
||||
)
|
||||
},
|
||||
errorTitle = "Failed to Update Profile"
|
||||
)
|
||||
|
||||
// Load current user data
|
||||
LaunchedEffect(Unit) {
|
||||
val token = TokenStorage.getToken()
|
||||
@@ -68,7 +81,7 @@ fun ProfileScreen(
|
||||
viewModel.resetUpdateProfileState()
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
errorMessage = (updateState as ApiResult.Error).message
|
||||
errorMessage = com.mycrib.android.util.ErrorMessageParser.parse((updateState as ApiResult.Error).message)
|
||||
isLoading = false
|
||||
successMessage = ""
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.ui.components.HandleErrors
|
||||
import com.mycrib.android.ui.components.auth.AuthHeader
|
||||
import com.mycrib.android.ui.components.common.ErrorCard
|
||||
import com.mycrib.android.viewmodel.AuthViewModel
|
||||
@@ -34,6 +35,13 @@ fun RegisterScreen(
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
val createState by viewModel.registerState.collectAsState()
|
||||
|
||||
// Handle errors for registration
|
||||
createState.HandleErrors(
|
||||
onRetry = { viewModel.register(username, email, password) },
|
||||
errorTitle = "Registration Failed"
|
||||
)
|
||||
|
||||
LaunchedEffect(createState) {
|
||||
when (createState) {
|
||||
is ApiResult.Success -> {
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mycrib.android.ui.components.HandleErrors
|
||||
import com.mycrib.android.ui.components.auth.AuthHeader
|
||||
import com.mycrib.android.ui.components.auth.RequirementItem
|
||||
import com.mycrib.android.ui.components.common.ErrorCard
|
||||
@@ -35,8 +36,14 @@ fun ResetPasswordScreen(
|
||||
val resetPasswordState by viewModel.resetPasswordState.collectAsState()
|
||||
val currentStep by viewModel.currentStep.collectAsState()
|
||||
|
||||
// Handle errors for password reset
|
||||
resetPasswordState.HandleErrors(
|
||||
onRetry = { viewModel.resetPassword(newPassword, confirmPassword) },
|
||||
errorTitle = "Password Reset Failed"
|
||||
)
|
||||
|
||||
val errorMessage = when (resetPasswordState) {
|
||||
is ApiResult.Error -> (resetPasswordState as ApiResult.Error).message
|
||||
is ApiResult.Error -> com.mycrib.android.util.ErrorMessageParser.parse((resetPasswordState as ApiResult.Error).message)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ fun ResidenceDetailScreen(
|
||||
// Handle generate report state
|
||||
// Handle errors for generate report
|
||||
generateReportState.HandleErrors(
|
||||
onRetry = { residenceViewModel.generateMaintenanceReport(residenceId) },
|
||||
onRetry = { residenceViewModel.generateTasksReport(residenceId) },
|
||||
errorTitle = "Failed to Generate Report"
|
||||
)
|
||||
|
||||
@@ -597,7 +597,7 @@ fun ResidenceDetailScreen(
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Error loading tasks: ${(tasksState as ApiResult.Error).message}",
|
||||
text = "Error loading tasks: ${com.mycrib.android.util.ErrorMessageParser.parse((tasksState as ApiResult.Error).message)}",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
@@ -691,5 +691,4 @@ fun ResidenceDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ fun ResidenceFormScreen(
|
||||
// Error message
|
||||
if (operationState is ApiResult.Error) {
|
||||
Text(
|
||||
text = (operationState as ApiResult.Error).message,
|
||||
text = com.mycrib.android.util.ErrorMessageParser.parse((operationState as ApiResult.Error).message),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
|
||||
@@ -399,5 +399,4 @@ fun ResidencesScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ fun TasksScreen(
|
||||
// Show error dialog when tasks fail to load
|
||||
LaunchedEffect(tasksState) {
|
||||
if (tasksState is ApiResult.Error) {
|
||||
errorMessage = (tasksState as ApiResult.Error).message
|
||||
errorMessage = com.mycrib.android.util.ErrorMessageParser.parse((tasksState as ApiResult.Error).message)
|
||||
showErrorDialog = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.ui.components.HandleErrors
|
||||
import com.mycrib.android.ui.components.auth.AuthHeader
|
||||
import com.mycrib.android.ui.components.common.ErrorCard
|
||||
import com.mycrib.android.viewmodel.AuthViewModel
|
||||
@@ -34,6 +35,12 @@ fun VerifyEmailScreen(
|
||||
|
||||
val verifyState by viewModel.verifyEmailState.collectAsState()
|
||||
|
||||
// Handle errors for email verification
|
||||
verifyState.HandleErrors(
|
||||
onRetry = { viewModel.verifyEmail(code) },
|
||||
errorTitle = "Verification Failed"
|
||||
)
|
||||
|
||||
LaunchedEffect(verifyState) {
|
||||
when (verifyState) {
|
||||
is ApiResult.Success -> {
|
||||
@@ -41,7 +48,7 @@ fun VerifyEmailScreen(
|
||||
onVerifySuccess()
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
errorMessage = (verifyState as ApiResult.Error).message
|
||||
errorMessage = com.mycrib.android.util.ErrorMessageParser.parse((verifyState as ApiResult.Error).message)
|
||||
isLoading = false
|
||||
}
|
||||
is ApiResult.Loading -> {
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mycrib.android.ui.components.HandleErrors
|
||||
import com.mycrib.android.ui.components.auth.AuthHeader
|
||||
import com.mycrib.android.ui.components.common.ErrorCard
|
||||
import com.mycrib.android.viewmodel.PasswordResetViewModel
|
||||
@@ -31,6 +32,12 @@ fun VerifyResetCodeScreen(
|
||||
val verifyCodeState by viewModel.verifyCodeState.collectAsState()
|
||||
val currentStep by viewModel.currentStep.collectAsState()
|
||||
|
||||
// Handle errors for code verification
|
||||
verifyCodeState.HandleErrors(
|
||||
onRetry = { viewModel.verifyResetCode(email, code) },
|
||||
errorTitle = "Code Verification Failed"
|
||||
)
|
||||
|
||||
// Handle automatic navigation to next step
|
||||
LaunchedEffect(currentStep) {
|
||||
if (currentStep == com.mycrib.android.viewmodel.PasswordResetStep.RESET_PASSWORD) {
|
||||
@@ -39,7 +46,7 @@ fun VerifyResetCodeScreen(
|
||||
}
|
||||
|
||||
val errorMessage = when (verifyCodeState) {
|
||||
is ApiResult.Error -> (verifyCodeState as ApiResult.Error).message
|
||||
is ApiResult.Error -> com.mycrib.android.util.ErrorMessageParser.parse((verifyCodeState as ApiResult.Error).message)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.mycrib.android.util
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Utility object for parsing and cleaning error messages from API responses
|
||||
*/
|
||||
object ErrorMessageParser {
|
||||
|
||||
/**
|
||||
* Parses error messages to extract user-friendly text
|
||||
* If the error message is JSON, extract relevant error details
|
||||
* @param rawMessage The raw error message from the API
|
||||
* @return A user-friendly error message
|
||||
*/
|
||||
fun parse(rawMessage: String): String {
|
||||
val trimmed = rawMessage.trim()
|
||||
|
||||
// Check if the message looks like JSON (starts with { or [)
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
||||
// Not JSON, return as-is
|
||||
return rawMessage
|
||||
}
|
||||
|
||||
// If it's JSON, it's not meant for user display
|
||||
// Try to parse and extract meaningful error info
|
||||
return try {
|
||||
val jsonElement = Json.parseToJsonElement(trimmed)
|
||||
|
||||
if (jsonElement is kotlinx.serialization.json.JsonObject) {
|
||||
val json = jsonElement.jsonObject
|
||||
|
||||
// Try to find common error fields
|
||||
json["error"]?.jsonPrimitive?.content?.let { return it }
|
||||
json["message"]?.jsonPrimitive?.content?.let { return it }
|
||||
json["detail"]?.jsonPrimitive?.content?.let { return it }
|
||||
|
||||
// Check if this looks like a data object (has id, title/name, etc)
|
||||
// rather than an error response
|
||||
if (json.containsKey("id") && (json.containsKey("title") || json.containsKey("name"))) {
|
||||
return "Request failed. Please check your input and try again."
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't extract a message, return a generic error
|
||||
"An error occurred. Please try again."
|
||||
} catch (e: Exception) {
|
||||
// JSON parsing failed, return generic error
|
||||
"An error occurred. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,10 @@ struct ContractorDetailView: View {
|
||||
.onAppear {
|
||||
viewModel.loadContractorDetail(id: contractorId)
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { viewModel.loadContractorDetail(id: contractorId) }
|
||||
)
|
||||
}
|
||||
|
||||
private func deleteContractor() {
|
||||
|
||||
@@ -281,6 +281,10 @@ struct ContractorFormSheet: View {
|
||||
loadContractorData()
|
||||
loadContractorSpecialties()
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { saveContractor() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class ContractorViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
break
|
||||
@@ -87,7 +87,7 @@ class ContractorViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
break
|
||||
@@ -119,7 +119,7 @@ class ContractorViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isCreating = false
|
||||
}
|
||||
sharedViewModel.resetCreateState()
|
||||
@@ -153,7 +153,7 @@ class ContractorViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isUpdating = false
|
||||
}
|
||||
sharedViewModel.resetUpdateState()
|
||||
@@ -187,7 +187,7 @@ class ContractorViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isDeleting = false
|
||||
}
|
||||
sharedViewModel.resetDeleteState()
|
||||
@@ -210,7 +210,7 @@ class ContractorViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
}
|
||||
sharedViewModel.resetToggleFavoriteState()
|
||||
completion(false)
|
||||
|
||||
@@ -166,6 +166,10 @@ struct ContractorsListView: View {
|
||||
.onChange(of: searchText) { newValue in
|
||||
loadContractors()
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { loadContractors() }
|
||||
)
|
||||
}
|
||||
|
||||
private func loadContractors(forceRefresh: Bool = false) {
|
||||
|
||||
@@ -57,7 +57,7 @@ class DocumentViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
break
|
||||
@@ -142,7 +142,7 @@ class DocumentViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
sharedViewModel.resetCreateState()
|
||||
@@ -219,7 +219,7 @@ class DocumentViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
sharedViewModel.resetUpdateState()
|
||||
@@ -251,7 +251,7 @@ class DocumentViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
sharedViewModel.resetDeleteState()
|
||||
|
||||
50
iosApp/iosApp/Helpers/ErrorMessageParser.swift
Normal file
50
iosApp/iosApp/Helpers/ErrorMessageParser.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
/// Utility for parsing and cleaning error messages from API responses
|
||||
enum ErrorMessageParser {
|
||||
|
||||
/// Parses error messages to extract user-friendly text
|
||||
/// If the error message is JSON, extract relevant error details
|
||||
/// - Parameter rawMessage: The raw error message from the API
|
||||
/// - Returns: A user-friendly error message
|
||||
static func parse(_ rawMessage: String) -> String {
|
||||
// Check if the message looks like JSON (starts with { or [)
|
||||
let trimmed = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("{") || trimmed.hasPrefix("[") else {
|
||||
// Not JSON, return as-is
|
||||
return rawMessage
|
||||
}
|
||||
|
||||
// If it's JSON, it's not meant for user display
|
||||
// Try to parse and extract meaningful error info
|
||||
guard let data = trimmed.data(using: .utf8) else {
|
||||
return "An error occurred. Please try again."
|
||||
}
|
||||
|
||||
do {
|
||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
// Try to find common error fields
|
||||
if let errorMsg = json["error"] as? String {
|
||||
return errorMsg
|
||||
}
|
||||
if let message = json["message"] as? String {
|
||||
return message
|
||||
}
|
||||
if let detail = json["detail"] as? String {
|
||||
return detail
|
||||
}
|
||||
|
||||
// Check if this looks like a data object (has id, title, etc)
|
||||
// rather than an error response
|
||||
if json["id"] != nil && (json["title"] != nil || json["name"] != nil) {
|
||||
return "Request failed. Please check your input and try again."
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// JSON parsing failed
|
||||
}
|
||||
|
||||
// If we couldn't parse or extract a message, return a generic error
|
||||
return "An error occurred. Please try again."
|
||||
}
|
||||
}
|
||||
@@ -265,6 +265,10 @@ struct LoginView: View {
|
||||
showPasswordReset = true
|
||||
}
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { viewModel.login() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,10 +108,10 @@ class LoginViewModel: ObservableObject {
|
||||
case 500...599:
|
||||
self.errorMessage = "Server error. Please try again later."
|
||||
default:
|
||||
self.errorMessage = self.cleanErrorMessage(error.message)
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
}
|
||||
} else {
|
||||
self.errorMessage = self.cleanErrorMessage(error.message)
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
}
|
||||
|
||||
print("API Error: \(error.message)")
|
||||
|
||||
@@ -110,6 +110,10 @@ struct ForgotPasswordView: View {
|
||||
.onAppear {
|
||||
isEmailFocused = true
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { viewModel.requestPasswordReset() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,10 +290,10 @@ class PasswordResetViewModel: ObservableObject {
|
||||
} else if message.contains("Invalid") && message.contains("token") {
|
||||
self.errorMessage = "Invalid or expired reset token. Please start over."
|
||||
} else {
|
||||
self.errorMessage = message
|
||||
self.errorMessage = ErrorMessageParser.parse(message)
|
||||
}
|
||||
} else {
|
||||
self.errorMessage = errorResult.message
|
||||
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
|
||||
}
|
||||
|
||||
print("API Error: \(errorResult.message)")
|
||||
|
||||
@@ -232,6 +232,10 @@ struct ResetPasswordView: View {
|
||||
.onAppear {
|
||||
focusedField = .newPassword
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { viewModel.resetPassword() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,10 @@ struct VerifyResetCodeView: View {
|
||||
.onAppear {
|
||||
isCodeFocused = true
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { viewModel.verifyResetCode() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,10 @@ struct ProfileView: View {
|
||||
.onChange(of: viewModel.email) { _, _ in
|
||||
viewModel.clearMessages()
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { viewModel.updateProfile() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ class ProfileViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoadingUser = false
|
||||
}
|
||||
sharedViewModel.resetCurrentUserState()
|
||||
@@ -114,7 +114,7 @@ class ProfileViewModel: ObservableObject {
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.isLoading = false
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.successMessage = nil
|
||||
}
|
||||
sharedViewModel.resetUpdateProfileState()
|
||||
|
||||
@@ -127,6 +127,10 @@ struct RegisterView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { viewModel.register() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ class RegisterViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
sharedViewModel.resetRegisterState()
|
||||
|
||||
@@ -89,7 +89,7 @@ struct JoinResidenceView: View {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
viewModel.errorMessage = error.message
|
||||
viewModel.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
viewModel.sharedViewModel.resetJoinResidenceState()
|
||||
}
|
||||
break
|
||||
|
||||
@@ -100,7 +100,7 @@ struct ManageUsersView: View {
|
||||
self.ownerId = responseData.ownerId as? Int32
|
||||
self.isLoading = false
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.errorMessage = errorResult.message
|
||||
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
|
||||
self.isLoading = false
|
||||
} else {
|
||||
self.errorMessage = "Failed to load users"
|
||||
@@ -149,7 +149,7 @@ struct ManageUsersView: View {
|
||||
self.shareCode = successResult.data
|
||||
self.isGeneratingCode = false
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.errorMessage = errorResult.message
|
||||
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
|
||||
self.isGeneratingCode = false
|
||||
} else {
|
||||
self.errorMessage = "Failed to generate share code"
|
||||
@@ -177,7 +177,7 @@ struct ManageUsersView: View {
|
||||
// Remove user from local list
|
||||
self.users.removeAll { $0.id == userId }
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.errorMessage = errorResult.message
|
||||
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
|
||||
} else {
|
||||
self.errorMessage = "Failed to remove user"
|
||||
}
|
||||
|
||||
@@ -148,6 +148,10 @@ struct ResidenceDetailView: View {
|
||||
.onAppear {
|
||||
loadResidenceData()
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { loadResidenceData() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,10 +162,6 @@ private extension ResidenceDetailView {
|
||||
var mainContent: some View {
|
||||
if !hasAppeared || viewModel.isLoading {
|
||||
loadingView
|
||||
} else if let error = viewModel.errorMessage {
|
||||
ErrorView(message: error) {
|
||||
loadResidenceData()
|
||||
}
|
||||
} else if let residence = viewModel.selectedResidence {
|
||||
contentView(for: residence)
|
||||
}
|
||||
@@ -328,7 +328,7 @@ private extension ResidenceDetailView {
|
||||
if result is ApiResultSuccess<KotlinUnit> {
|
||||
dismiss()
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.viewModel.errorMessage = errorResult.message
|
||||
self.viewModel.errorMessage = ErrorMessageParser.parse(errorResult.message)
|
||||
} else {
|
||||
self.viewModel.errorMessage = "Failed to delete residence"
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ class ResidenceViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
break
|
||||
@@ -74,7 +74,7 @@ class ResidenceViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
break
|
||||
@@ -93,7 +93,7 @@ class ResidenceViewModel: ObservableObject {
|
||||
self.selectedResidence = success.data
|
||||
self.isLoading = false
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ class ResidenceViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
sharedViewModel.resetCreateState()
|
||||
@@ -156,7 +156,7 @@ class ResidenceViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
sharedViewModel.resetUpdateState()
|
||||
|
||||
@@ -19,10 +19,6 @@ struct ResidencesListView: View {
|
||||
.font(.body)
|
||||
.foregroundColor(Color(.secondaryLabel))
|
||||
}
|
||||
} else if let error = viewModel.errorMessage {
|
||||
ErrorView(message: error) {
|
||||
viewModel.loadMyResidences()
|
||||
}
|
||||
} else if let response = viewModel.myResidences {
|
||||
if response.residences.isEmpty {
|
||||
EmptyResidencesView()
|
||||
@@ -100,6 +96,10 @@ struct ResidencesListView: View {
|
||||
.onAppear {
|
||||
viewModel.loadMyResidences()
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { viewModel.loadMyResidences() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,10 @@ struct ResidenceFormView: View {
|
||||
loadResidenceTypes()
|
||||
initializeForm()
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { submitForm() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,10 @@ struct CompleteTaskView: View {
|
||||
.onAppear {
|
||||
contractorViewModel.loadContractors()
|
||||
}
|
||||
.handleErrors(
|
||||
error: errorMessage,
|
||||
onRetry: { handleComplete() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,10 @@ struct EditTaskView: View {
|
||||
.onAppear {
|
||||
loadLookups()
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { submitForm() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,3 +185,4 @@ struct EditTaskView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ class TaskViewModel: ObservableObject {
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.message
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
sharedViewModel.resetAddTaskState()
|
||||
|
||||
@@ -146,6 +146,10 @@ struct VerifyEmailView: View {
|
||||
onVerifySuccess()
|
||||
}
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { viewModel.verifyEmail() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user