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()) { if (response.status.isSuccess()) {
ApiResult.Success(response.body()) ApiResult.Success(response.body())
} else { } else {
val errorMessage = try { val errorBody = try {
val errorBody: String = response.body() response.body<String>()
"Failed to create contractor: $errorBody"
} catch (e: Exception) { } catch (e: Exception) {
"Failed to create contractor" "Failed to create contractor"
} }
ApiResult.Error(errorMessage, response.status.value) ApiResult.Error(errorBody, response.status.value)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Unknown error occurred")
@@ -86,13 +85,12 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
ApiResult.Success(response.body()) ApiResult.Success(response.body())
} else { } else {
val errorMessage = try { val errorBody = try {
val errorBody: String = response.body() response.body<String>()
"Failed to update contractor: $errorBody"
} catch (e: Exception) { } catch (e: Exception) {
"Failed to update contractor" "Failed to update contractor"
} }
ApiResult.Error(errorMessage, response.status.value) ApiResult.Error(errorBody, response.status.value)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") 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()) { if (response.status.isSuccess()) {
ApiResult.Success(response.body()) ApiResult.Success(response.body())
} else { } else {
val errorMessage = try { val errorBody = try {
val errorBody: String = response.body() response.body<String>()
"Failed to create document: $errorBody"
} catch (e: Exception) { } catch (e: Exception) {
"Failed to create document" "Failed to create document"
} }
ApiResult.Error(errorMessage, response.status.value) ApiResult.Error(errorBody, response.status.value)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Unknown error occurred")
@@ -284,13 +283,12 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
ApiResult.Success(response.body()) ApiResult.Success(response.body())
} else { } else {
val errorMessage = try { val errorBody = try {
val errorBody: String = response.body() response.body<String>()
"Failed to update document: $errorBody"
} catch (e: Exception) { } catch (e: Exception) {
"Failed to update document" "Failed to update document"
} }
ApiResult.Error(errorMessage, response.status.value) ApiResult.Error(errorBody, response.status.value)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Unknown error occurred")
@@ -403,13 +401,12 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
ApiResult.Success(response.body()) ApiResult.Success(response.body())
} else { } else {
val errorMessage = try { val errorBody = try {
val errorBody: String = response.body() response.body<String>()
"Failed to upload image: $errorBody"
} catch (e: Exception) { } catch (e: Exception) {
"Failed to upload image" "Failed to upload image"
} }
ApiResult.Error(errorMessage, response.status.value) ApiResult.Error(errorBody, response.status.value)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Unknown error occurred")

View File

@@ -125,7 +125,7 @@ fun <T> ApiResult<T>.HandleErrors(
LaunchedEffect(this) { LaunchedEffect(this) {
if (this@HandleErrors is ApiResult.Error) { 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 showErrorDialog = true
} }
} }

View File

@@ -13,7 +13,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.mycrib.android.ui.components.AddNewTaskWithResidenceDialog 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.CompleteTaskDialog
import com.mycrib.android.ui.components.HandleErrors
import com.mycrib.android.ui.components.task.TaskCard import com.mycrib.android.ui.components.task.TaskCard
import com.mycrib.android.ui.components.task.DynamicTaskKanbanView import com.mycrib.android.ui.components.task.DynamicTaskKanbanView
import com.mycrib.android.viewmodel.ResidenceViewModel import com.mycrib.android.viewmodel.ResidenceViewModel
@@ -59,6 +61,12 @@ fun AllTasksScreen(
} }
// Handle task creation success // Handle task creation success
// Handle errors for task creation
createTaskState.HandleErrors(
onRetry = { /* Retry handled in dialog */ },
errorTitle = "Failed to Create Task"
)
LaunchedEffect(createTaskState) { LaunchedEffect(createTaskState) {
println("AllTasksScreen: createTaskState changed to $createTaskState") println("AllTasksScreen: createTaskState changed to $createTaskState")
when (createTaskState) { when (createTaskState) {
@@ -68,15 +76,7 @@ fun AllTasksScreen(
viewModel.resetAddTaskState() viewModel.resetAddTaskState()
viewModel.loadTasks() viewModel.loadTasks()
} }
is ApiResult.Error -> { else -> {}
println("AllTasksScreen: Task creation error: ${(createTaskState as ApiResult.Error).message}")
}
is ApiResult.Loading -> {
println("AllTasksScreen: Task creation loading")
}
else -> {
println("AllTasksScreen: Task creation idle")
}
} }
} }
@@ -115,48 +115,12 @@ fun AllTasksScreen(
) )
} }
) { paddingValues -> ) { paddingValues ->
when (tasksState) { ApiResultHandler(
is ApiResult.Idle, is ApiResult.Loading -> { state = tasksState,
Box( onRetry = { viewModel.loadTasks(forceRefresh = true) },
modifier = Modifier modifier = Modifier.padding(paddingValues),
.fillMaxSize() errorTitle = "Failed to Load Tasks"
.padding(paddingValues), ) { taskData ->
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
val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() } val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
if (hasNoTasks) { if (hasNoTasks) {
@@ -263,10 +227,7 @@ fun AllTasksScreen(
) )
} }
} }
else -> {}
} }
}
if (showCompleteDialog && selectedTask != null) { if (showCompleteDialog && selectedTask != null) {
CompleteTaskDialog( CompleteTaskDialog(
@@ -303,7 +264,7 @@ fun AllTasksScreen(
}, },
isLoading = createTaskState is ApiResult.Loading, isLoading = createTaskState is ApiResult.Loading,
errorMessage = if (createTaskState is ApiResult.Error) { errorMessage = if (createTaskState is ApiResult.Error) {
(createTaskState as ApiResult.Error).message com.mycrib.android.util.ErrorMessageParser.parse((createTaskState as ApiResult.Error).message)
} else null } else null
) )
} }

View File

@@ -18,6 +18,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.mycrib.android.ui.components.AddContractorDialog 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.android.viewmodel.ContractorViewModel
import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.ApiResult
@@ -39,6 +41,18 @@ fun ContractorDetailScreen(
viewModel.loadContractorDetail(contractorId) 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) { LaunchedEffect(deleteState) {
if (deleteState is ApiResult.Success) { if (deleteState is ApiResult.Success) {
viewModel.resetDeleteState() viewModel.resetDeleteState()
@@ -94,18 +108,14 @@ fun ContractorDetailScreen(
.padding(padding) .padding(padding)
.background(Color(0xFFF9FAFB)) .background(Color(0xFFF9FAFB))
) { ) {
when (val state = contractorState) { ApiResultHandler(
is ApiResult.Loading -> { state = contractorState,
Box( onRetry = { viewModel.loadContractorDetail(contractorId) },
modifier = Modifier.fillMaxSize(), errorTitle = "Failed to Load Contractor",
contentAlignment = Alignment.Center loadingContent = {
) { CircularProgressIndicator(color = Color(0xFF2563EB))
CircularProgressIndicator(color = Color(0xFF2563EB))
}
} }
is ApiResult.Success -> { ) { contractor ->
val contractor = state.data
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp), 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) { if (showEditDialog) {
AddContractorDialog( AddContractorDialog(

View File

@@ -21,6 +21,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.mycrib.android.ui.components.AddContractorDialog 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.android.viewmodel.ContractorViewModel
import com.mycrib.shared.models.ContractorSummary import com.mycrib.shared.models.ContractorSummary
import com.mycrib.shared.network.ApiResult 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) { LaunchedEffect(deleteState) {
if (deleteState is ApiResult.Success) { if (deleteState is ApiResult.Success) {
viewModel.loadContractors() viewModel.loadContractors()
@@ -216,81 +230,25 @@ fun ContractorsScreen(
} }
} }
when (val state = contractorsState) { ApiResultHandler(
is ApiResult.Loading -> { 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) { if (!isRefreshing) {
Box( CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
} }
} }
is ApiResult.Success -> { ) { state ->
val contractors = state.data.results val contractors = state.results
if (contractors.isEmpty()) { 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 -> {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -300,24 +258,55 @@ fun ContractorsScreen(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Icon( Icon(
Icons.Default.ErrorOutline, Icons.Default.PersonAdd,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
Text(state.message, color = MaterialTheme.colorScheme.error) Text(
Button( if (searchQuery.isNotEmpty() || selectedFilter != null || showFavoritesOnly)
onClick = { viewModel.loadContractors() }, "No contractors found"
colors = ButtonDefaults.buttonColors( else
containerColor = MaterialTheme.colorScheme.primary "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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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.android.viewmodel.DocumentViewModel
import com.mycrib.shared.models.* import com.mycrib.shared.models.*
import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.ApiResult
@@ -51,6 +53,12 @@ fun DocumentDetailScreen(
documentViewModel.loadDocumentDetail(documentId) documentViewModel.loadDocumentDetail(documentId)
} }
// Handle errors for document deletion
deleteState.HandleErrors(
onRetry = { documentViewModel.deleteDocument(documentId) },
errorTitle = "Failed to Delete Document"
)
// Handle successful deletion // Handle successful deletion
LaunchedEffect(deleteState) { LaunchedEffect(deleteState) {
if (deleteState is ApiResult.Success) { if (deleteState is ApiResult.Success) {
@@ -89,17 +97,11 @@ fun DocumentDetailScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
when (val state = documentState) { ApiResultHandler(
is ApiResult.Loading -> { state = documentState,
Box( onRetry = { documentViewModel.loadDocumentDetail(documentId) },
modifier = Modifier.fillMaxSize(), errorTitle = "Failed to Load Document"
contentAlignment = Alignment.Center ) { document ->
) {
CircularProgressIndicator()
}
}
is ApiResult.Success -> {
val document = state.data
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -409,16 +411,8 @@ fun DocumentDetailScreen(
} }
} }
} }
is ApiResult.Error -> {
ErrorState(
message = state.message,
onRetry = { documentViewModel.loadDocumentDetail(documentId) }
)
}
is ApiResult.Idle -> {}
} }
} }
}
// Delete confirmation dialog // Delete confirmation dialog
if (showDeleteDialog) { if (showDeleteDialog) {

View File

@@ -239,7 +239,7 @@ fun DocumentFormScreen(
} }
is ApiResult.Error -> { is ApiResult.Error -> {
Text( 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 color = MaterialTheme.colorScheme.error
) )
} }
@@ -596,7 +596,7 @@ fun DocumentFormScreen(
) )
) { ) {
Text( Text(
(operationState as ApiResult.Error).message, com.mycrib.android.util.ErrorMessageParser.parse((operationState as ApiResult.Error).message),
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onErrorContainer 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.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.mycrib.android.ui.components.HandleErrors
import com.mycrib.android.viewmodel.ResidenceViewModel import com.mycrib.android.viewmodel.ResidenceViewModel
import com.mycrib.repository.LookupsRepository import com.mycrib.repository.LookupsRepository
import com.mycrib.shared.models.* import com.mycrib.shared.models.*
@@ -49,6 +50,12 @@ fun EditTaskScreen(
var titleError by remember { mutableStateOf("") } var titleError by remember { mutableStateOf("") }
var dueDateError 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 // Handle update state changes
LaunchedEffect(updateTaskState) { LaunchedEffect(updateTaskState) {
when (updateTaskState) { when (updateTaskState) {
@@ -279,7 +286,7 @@ fun EditTaskScreen(
// Error message // Error message
if (updateTaskState is ApiResult.Error) { if (updateTaskState is ApiResult.Error) {
Text( Text(
text = (updateTaskState as ApiResult.Error).message, text = com.mycrib.android.util.ErrorMessageParser.parse((updateTaskState as ApiResult.Error).message),
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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.auth.AuthHeader
import com.mycrib.android.ui.components.common.ErrorCard import com.mycrib.android.ui.components.common.ErrorCard
import com.mycrib.android.viewmodel.PasswordResetViewModel import com.mycrib.android.viewmodel.PasswordResetViewModel
@@ -30,6 +31,15 @@ fun ForgotPasswordScreen(
val forgotPasswordState by viewModel.forgotPasswordState.collectAsState() val forgotPasswordState by viewModel.forgotPasswordState.collectAsState()
val currentStep by viewModel.currentStep.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 // Handle automatic navigation to next step
LaunchedEffect(currentStep) { LaunchedEffect(currentStep) {
when (currentStep) { when (currentStep) {
@@ -40,7 +50,7 @@ fun ForgotPasswordScreen(
} }
val errorMessage = when (forgotPasswordState) { 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 -> "" else -> ""
} }

View File

@@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.mycrib.android.ui.components.HandleErrors
import com.mycrib.android.ui.theme.AppRadius import com.mycrib.android.ui.theme.AppRadius
import com.mycrib.android.viewmodel.ResidenceViewModel import com.mycrib.android.viewmodel.ResidenceViewModel
import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.ApiResult
@@ -33,6 +34,12 @@ fun HomeScreen(
viewModel.loadResidenceSummary() viewModel.loadResidenceSummary()
} }
// Handle errors for loading summary
summaryState.HandleErrors(
onRetry = { viewModel.loadResidenceSummary() },
errorTitle = "Failed to Load Summary"
)
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( 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.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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.auth.AuthHeader
import com.mycrib.android.ui.components.common.ErrorCard import com.mycrib.android.ui.components.common.ErrorCard
import com.mycrib.android.viewmodel.AuthViewModel import com.mycrib.android.viewmodel.AuthViewModel
@@ -38,6 +39,12 @@ fun LoginScreen(
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
val loginState by viewModel.loginState.collectAsState() val loginState by viewModel.loginState.collectAsState()
// Handle errors for login
loginState.HandleErrors(
onRetry = { viewModel.login(username, password) },
errorTitle = "Login Failed"
)
// Handle login state changes // Handle login state changes
LaunchedEffect(loginState) { LaunchedEffect(loginState) {
when (loginState) { when (loginState) {
@@ -50,7 +57,7 @@ fun LoginScreen(
} }
val errorMessage = when (loginState) { 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 -> "" 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.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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.ui.components.common.ErrorCard
import com.mycrib.android.viewmodel.AuthViewModel import com.mycrib.android.viewmodel.AuthViewModel
import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.ApiResult
@@ -36,6 +37,18 @@ fun ProfileScreen(
val updateState by viewModel.updateProfileState.collectAsState() 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 // Load current user data
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val token = TokenStorage.getToken() val token = TokenStorage.getToken()
@@ -68,7 +81,7 @@ fun ProfileScreen(
viewModel.resetUpdateProfileState() viewModel.resetUpdateProfileState()
} }
is ApiResult.Error -> { is ApiResult.Error -> {
errorMessage = (updateState as ApiResult.Error).message errorMessage = com.mycrib.android.util.ErrorMessageParser.parse((updateState as ApiResult.Error).message)
isLoading = false isLoading = false
successMessage = "" 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.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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.auth.AuthHeader
import com.mycrib.android.ui.components.common.ErrorCard import com.mycrib.android.ui.components.common.ErrorCard
import com.mycrib.android.viewmodel.AuthViewModel import com.mycrib.android.viewmodel.AuthViewModel
@@ -34,6 +35,13 @@ fun RegisterScreen(
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
val createState by viewModel.registerState.collectAsState() val createState by viewModel.registerState.collectAsState()
// Handle errors for registration
createState.HandleErrors(
onRetry = { viewModel.register(username, email, password) },
errorTitle = "Registration Failed"
)
LaunchedEffect(createState) { LaunchedEffect(createState) {
when (createState) { when (createState) {
is ApiResult.Success -> { 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.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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.AuthHeader
import com.mycrib.android.ui.components.auth.RequirementItem import com.mycrib.android.ui.components.auth.RequirementItem
import com.mycrib.android.ui.components.common.ErrorCard import com.mycrib.android.ui.components.common.ErrorCard
@@ -35,8 +36,14 @@ fun ResetPasswordScreen(
val resetPasswordState by viewModel.resetPasswordState.collectAsState() val resetPasswordState by viewModel.resetPasswordState.collectAsState()
val currentStep by viewModel.currentStep.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) { 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 -> "" else -> ""
} }

View File

@@ -120,7 +120,7 @@ fun ResidenceDetailScreen(
// Handle generate report state // Handle generate report state
// Handle errors for generate report // Handle errors for generate report
generateReportState.HandleErrors( generateReportState.HandleErrors(
onRetry = { residenceViewModel.generateMaintenanceReport(residenceId) }, onRetry = { residenceViewModel.generateTasksReport(residenceId) },
errorTitle = "Failed to Generate Report" errorTitle = "Failed to Generate Report"
) )
@@ -597,7 +597,7 @@ fun ResidenceDetailScreen(
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) { ) {
Text( 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, color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
@@ -691,5 +691,4 @@ fun ResidenceDetailScreen(
} }
} }
} }
}
} }

View File

@@ -331,7 +331,7 @@ fun ResidenceFormScreen(
// Error message // Error message
if (operationState is ApiResult.Error) { if (operationState is ApiResult.Error) {
Text( Text(
text = (operationState as ApiResult.Error).message, text = com.mycrib.android.util.ErrorMessageParser.parse((operationState as ApiResult.Error).message),
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall 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 // Show error dialog when tasks fail to load
LaunchedEffect(tasksState) { LaunchedEffect(tasksState) {
if (tasksState is ApiResult.Error) { 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 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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.auth.AuthHeader
import com.mycrib.android.ui.components.common.ErrorCard import com.mycrib.android.ui.components.common.ErrorCard
import com.mycrib.android.viewmodel.AuthViewModel import com.mycrib.android.viewmodel.AuthViewModel
@@ -34,6 +35,12 @@ fun VerifyEmailScreen(
val verifyState by viewModel.verifyEmailState.collectAsState() val verifyState by viewModel.verifyEmailState.collectAsState()
// Handle errors for email verification
verifyState.HandleErrors(
onRetry = { viewModel.verifyEmail(code) },
errorTitle = "Verification Failed"
)
LaunchedEffect(verifyState) { LaunchedEffect(verifyState) {
when (verifyState) { when (verifyState) {
is ApiResult.Success -> { is ApiResult.Success -> {
@@ -41,7 +48,7 @@ fun VerifyEmailScreen(
onVerifySuccess() onVerifySuccess()
} }
is ApiResult.Error -> { is ApiResult.Error -> {
errorMessage = (verifyState as ApiResult.Error).message errorMessage = com.mycrib.android.util.ErrorMessageParser.parse((verifyState as ApiResult.Error).message)
isLoading = false isLoading = false
} }
is ApiResult.Loading -> { 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.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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.AuthHeader
import com.mycrib.android.ui.components.common.ErrorCard import com.mycrib.android.ui.components.common.ErrorCard
import com.mycrib.android.viewmodel.PasswordResetViewModel import com.mycrib.android.viewmodel.PasswordResetViewModel
@@ -31,6 +32,12 @@ fun VerifyResetCodeScreen(
val verifyCodeState by viewModel.verifyCodeState.collectAsState() val verifyCodeState by viewModel.verifyCodeState.collectAsState()
val currentStep by viewModel.currentStep.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 // Handle automatic navigation to next step
LaunchedEffect(currentStep) { LaunchedEffect(currentStep) {
if (currentStep == com.mycrib.android.viewmodel.PasswordResetStep.RESET_PASSWORD) { if (currentStep == com.mycrib.android.viewmodel.PasswordResetStep.RESET_PASSWORD) {
@@ -39,7 +46,7 @@ fun VerifyResetCodeScreen(
} }
val errorMessage = when (verifyCodeState) { 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 -> "" 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 { .onAppear {
viewModel.loadContractorDetail(id: contractorId) viewModel.loadContractorDetail(id: contractorId)
} }
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.loadContractorDetail(id: contractorId) }
)
} }
private func deleteContractor() { private func deleteContractor() {

View File

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

View File

@@ -57,7 +57,7 @@ class ContractorViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false self.isLoading = false
} }
break break
@@ -87,7 +87,7 @@ class ContractorViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false self.isLoading = false
} }
break break
@@ -119,7 +119,7 @@ class ContractorViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isCreating = false self.isCreating = false
} }
sharedViewModel.resetCreateState() sharedViewModel.resetCreateState()
@@ -153,7 +153,7 @@ class ContractorViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isUpdating = false self.isUpdating = false
} }
sharedViewModel.resetUpdateState() sharedViewModel.resetUpdateState()
@@ -187,7 +187,7 @@ class ContractorViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isDeleting = false self.isDeleting = false
} }
sharedViewModel.resetDeleteState() sharedViewModel.resetDeleteState()
@@ -210,7 +210,7 @@ class ContractorViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
} }
sharedViewModel.resetToggleFavoriteState() sharedViewModel.resetToggleFavoriteState()
completion(false) completion(false)

View File

@@ -166,6 +166,10 @@ struct ContractorsListView: View {
.onChange(of: searchText) { newValue in .onChange(of: searchText) { newValue in
loadContractors() loadContractors()
} }
.handleErrors(
error: viewModel.errorMessage,
onRetry: { loadContractors() }
)
} }
private func loadContractors(forceRefresh: Bool = false) { private func loadContractors(forceRefresh: Bool = false) {

View File

@@ -57,7 +57,7 @@ class DocumentViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false self.isLoading = false
} }
break break
@@ -142,7 +142,7 @@ class DocumentViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false self.isLoading = false
} }
sharedViewModel.resetCreateState() sharedViewModel.resetCreateState()
@@ -219,7 +219,7 @@ class DocumentViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false self.isLoading = false
} }
sharedViewModel.resetUpdateState() sharedViewModel.resetUpdateState()
@@ -251,7 +251,7 @@ class DocumentViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false self.isLoading = false
} }
sharedViewModel.resetDeleteState() 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 showPasswordReset = true
} }
} }
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.login() }
)
} }
} }

View File

@@ -108,10 +108,10 @@ class LoginViewModel: ObservableObject {
case 500...599: case 500...599:
self.errorMessage = "Server error. Please try again later." self.errorMessage = "Server error. Please try again later."
default: default:
self.errorMessage = self.cleanErrorMessage(error.message) self.errorMessage = ErrorMessageParser.parse(error.message)
} }
} else { } else {
self.errorMessage = self.cleanErrorMessage(error.message) self.errorMessage = ErrorMessageParser.parse(error.message)
} }
print("API Error: \(error.message)") print("API Error: \(error.message)")

View File

@@ -110,6 +110,10 @@ struct ForgotPasswordView: View {
.onAppear { .onAppear {
isEmailFocused = true 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") { } else if message.contains("Invalid") && message.contains("token") {
self.errorMessage = "Invalid or expired reset token. Please start over." self.errorMessage = "Invalid or expired reset token. Please start over."
} else { } else {
self.errorMessage = message self.errorMessage = ErrorMessageParser.parse(message)
} }
} else { } else {
self.errorMessage = errorResult.message self.errorMessage = ErrorMessageParser.parse(errorResult.message)
} }
print("API Error: \(errorResult.message)") print("API Error: \(errorResult.message)")

View File

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

View File

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

View File

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

View File

@@ -60,7 +60,7 @@ class ProfileViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoadingUser = false self.isLoadingUser = false
} }
sharedViewModel.resetCurrentUserState() sharedViewModel.resetCurrentUserState()
@@ -114,7 +114,7 @@ class ProfileViewModel: ObservableObject {
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.isLoading = false self.isLoading = false
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.successMessage = nil self.successMessage = nil
} }
sharedViewModel.resetUpdateProfileState() 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 break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false self.isLoading = false
} }
sharedViewModel.resetRegisterState() sharedViewModel.resetRegisterState()

View File

@@ -89,7 +89,7 @@ struct JoinResidenceView: View {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
viewModel.errorMessage = error.message viewModel.errorMessage = ErrorMessageParser.parse(error.message)
viewModel.sharedViewModel.resetJoinResidenceState() viewModel.sharedViewModel.resetJoinResidenceState()
} }
break break

View File

@@ -100,7 +100,7 @@ struct ManageUsersView: View {
self.ownerId = responseData.ownerId as? Int32 self.ownerId = responseData.ownerId as? Int32
self.isLoading = false self.isLoading = false
} else if let errorResult = result as? ApiResultError { } else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message self.errorMessage = ErrorMessageParser.parse(errorResult.message)
self.isLoading = false self.isLoading = false
} else { } else {
self.errorMessage = "Failed to load users" self.errorMessage = "Failed to load users"
@@ -149,7 +149,7 @@ struct ManageUsersView: View {
self.shareCode = successResult.data self.shareCode = successResult.data
self.isGeneratingCode = false self.isGeneratingCode = false
} else if let errorResult = result as? ApiResultError { } else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message self.errorMessage = ErrorMessageParser.parse(errorResult.message)
self.isGeneratingCode = false self.isGeneratingCode = false
} else { } else {
self.errorMessage = "Failed to generate share code" self.errorMessage = "Failed to generate share code"
@@ -177,7 +177,7 @@ struct ManageUsersView: View {
// Remove user from local list // Remove user from local list
self.users.removeAll { $0.id == userId } self.users.removeAll { $0.id == userId }
} else if let errorResult = result as? ApiResultError { } else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message self.errorMessage = ErrorMessageParser.parse(errorResult.message)
} else { } else {
self.errorMessage = "Failed to remove user" self.errorMessage = "Failed to remove user"
} }

View File

@@ -148,6 +148,10 @@ struct ResidenceDetailView: View {
.onAppear { .onAppear {
loadResidenceData() loadResidenceData()
} }
.handleErrors(
error: viewModel.errorMessage,
onRetry: { loadResidenceData() }
)
} }
} }
@@ -158,10 +162,6 @@ private extension ResidenceDetailView {
var mainContent: some View { var mainContent: some View {
if !hasAppeared || viewModel.isLoading { if !hasAppeared || viewModel.isLoading {
loadingView loadingView
} else if let error = viewModel.errorMessage {
ErrorView(message: error) {
loadResidenceData()
}
} else if let residence = viewModel.selectedResidence { } else if let residence = viewModel.selectedResidence {
contentView(for: residence) contentView(for: residence)
} }
@@ -328,7 +328,7 @@ private extension ResidenceDetailView {
if result is ApiResultSuccess<KotlinUnit> { if result is ApiResultSuccess<KotlinUnit> {
dismiss() dismiss()
} else if let errorResult = result as? ApiResultError { } else if let errorResult = result as? ApiResultError {
self.viewModel.errorMessage = errorResult.message self.viewModel.errorMessage = ErrorMessageParser.parse(errorResult.message)
} else { } else {
self.viewModel.errorMessage = "Failed to delete residence" self.viewModel.errorMessage = "Failed to delete residence"
} }

View File

@@ -44,7 +44,7 @@ class ResidenceViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false self.isLoading = false
} }
break break
@@ -74,7 +74,7 @@ class ResidenceViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false self.isLoading = false
} }
break break
@@ -93,7 +93,7 @@ class ResidenceViewModel: ObservableObject {
self.selectedResidence = success.data self.selectedResidence = success.data
self.isLoading = false self.isLoading = false
} else if let error = result as? ApiResultError { } else if let error = result as? ApiResultError {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false self.isLoading = false
} }
} }
@@ -122,7 +122,7 @@ class ResidenceViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false self.isLoading = false
} }
sharedViewModel.resetCreateState() sharedViewModel.resetCreateState()
@@ -156,7 +156,7 @@ class ResidenceViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false self.isLoading = false
} }
sharedViewModel.resetUpdateState() sharedViewModel.resetUpdateState()

View File

@@ -19,10 +19,6 @@ struct ResidencesListView: View {
.font(.body) .font(.body)
.foregroundColor(Color(.secondaryLabel)) .foregroundColor(Color(.secondaryLabel))
} }
} else if let error = viewModel.errorMessage {
ErrorView(message: error) {
viewModel.loadMyResidences()
}
} else if let response = viewModel.myResidences { } else if let response = viewModel.myResidences {
if response.residences.isEmpty { if response.residences.isEmpty {
EmptyResidencesView() EmptyResidencesView()
@@ -100,6 +96,10 @@ struct ResidencesListView: View {
.onAppear { .onAppear {
viewModel.loadMyResidences() viewModel.loadMyResidences()
} }
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.loadMyResidences() }
)
} }
} }

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ class TaskViewModel: ObservableObject {
break break
} else if let error = state as? ApiResultError { } else if let error = state as? ApiResultError {
await MainActor.run { await MainActor.run {
self.errorMessage = error.message self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false self.isLoading = false
} }
sharedViewModel.resetAddTaskState() sharedViewModel.resetAddTaskState()

View File

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