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:
Trey t
2025-11-14 22:59:42 -06:00
parent 225bdbc2bc
commit 2730c94e4d
48 changed files with 415 additions and 265 deletions

View File

@@ -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")

View File

@@ -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")

View File

@@ -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
}
}

View File

@@ -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
)
}

View File

@@ -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(

View File

@@ -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 -> {}
}
}
}

View File

@@ -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) {

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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 -> ""
}

View File

@@ -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(

View File

@@ -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 -> ""
}

View File

@@ -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 = ""
}

View File

@@ -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 -> {

View File

@@ -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 -> ""
}

View File

@@ -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(
}
}
}
}
}

View File

@@ -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
)

View File

@@ -399,5 +399,4 @@ fun ResidencesScreen(
}
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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 -> {

View File

@@ -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 -> ""
}

View File

@@ -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."
}
}
}

View File

@@ -213,6 +213,10 @@ struct ContractorDetailView: View {
.onAppear {
viewModel.loadContractorDetail(id: contractorId)
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.loadContractorDetail(id: contractorId) }
)
}
private func deleteContractor() {

View File

@@ -281,6 +281,10 @@ struct ContractorFormSheet: View {
loadContractorData()
loadContractorSpecialties()
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { saveContractor() }
)
}
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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()

View 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."
}
}

View File

@@ -265,6 +265,10 @@ struct LoginView: View {
showPasswordReset = true
}
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.login() }
)
}
}

View File

@@ -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)")

View File

@@ -110,6 +110,10 @@ struct ForgotPasswordView: View {
.onAppear {
isEmailFocused = true
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.requestPasswordReset() }
)
}
}
}

View File

@@ -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)")

View File

@@ -232,6 +232,10 @@ struct ResetPasswordView: View {
.onAppear {
focusedField = .newPassword
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.resetPassword() }
)
}
}

View File

@@ -158,6 +158,10 @@ struct VerifyResetCodeView: View {
.onAppear {
isCodeFocused = true
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.verifyResetCode() }
)
}
}
}

View File

@@ -132,6 +132,10 @@ struct ProfileView: View {
.onChange(of: viewModel.email) { _, _ in
viewModel.clearMessages()
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.updateProfile() }
)
}
}
}

View File

@@ -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()

View File

@@ -127,6 +127,10 @@ struct RegisterView: View {
}
)
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.register() }
)
}
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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()

View File

@@ -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() }
)
}
}

View File

@@ -177,6 +177,10 @@ struct ResidenceFormView: View {
loadResidenceTypes()
initializeForm()
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { submitForm() }
)
}
}

View File

@@ -278,6 +278,10 @@ struct CompleteTaskView: View {
.onAppear {
contractorViewModel.loadContractors()
}
.handleErrors(
error: errorMessage,
onRetry: { handleComplete() }
)
}
}

View File

@@ -128,6 +128,10 @@ struct EditTaskView: View {
.onAppear {
loadLookups()
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { submitForm() }
)
}
}
@@ -181,3 +185,4 @@ struct EditTaskView: View {
}
}
}

View File

@@ -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()

View File

@@ -146,6 +146,10 @@ struct VerifyEmailView: View {
onVerifySuccess()
}
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.verifyEmail() }
)
}
}
}