Improve error message handling with user-friendly messages

- Add ErrorMessageParser in Kotlin and Swift to detect network errors
  and technical messages, replacing them with human-readable text
- Update all ViewModels to use ErrorMessageParser.parse() for error display
- Remove redundant error popup from LoginView (error shows inline only)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-11 20:46:43 -06:00
parent 1839bd0e11
commit 258ccf7354
18 changed files with 201 additions and 66 deletions

View File

@@ -9,23 +9,58 @@ import kotlinx.serialization.json.jsonPrimitive
*/
object ErrorMessageParser {
// Network/connection error patterns to detect
private val networkErrorPatterns = listOf(
"Could not connect to the server" to "Unable to connect to the server. Please check your internet connection.",
"NSURLErrorDomain" to "Unable to connect to the server. Please check your internet connection.",
"UnresolvedAddressException" to "Unable to connect to the server. Please check your internet connection.",
"ConnectException" to "Unable to connect to the server. Please check your internet connection.",
"SocketTimeoutException" to "Request timed out. Please try again.",
"TimeoutException" to "Request timed out. Please try again.",
"No address associated" to "Unable to connect to the server. Please check your internet connection.",
"Network is unreachable" to "No internet connection. Please check your network settings.",
"Connection refused" to "Unable to connect to the server. The server may be down.",
"Connection reset" to "Connection was interrupted. Please try again.",
"SSLHandshakeException" to "Secure connection failed. Please try again.",
"CertificateException" to "Secure connection failed. Please try again.",
"UnknownHostException" to "Unable to connect to the server. Please check your internet connection.",
"java.net.SocketException" to "Connection error. Please try again.",
"CFNetwork" to "Unable to connect to the server. Please check your internet connection.",
"kCFStreamError" to "Unable to connect to the server. Please check your internet connection.",
"Code=-1004" to "Unable to connect to the server. Please check your internet connection.",
"Code=-1009" to "No internet connection. Please check your network settings.",
"Code=-1001" to "Request timed out. Please try again."
)
/**
* 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
* Handles network errors, JSON error responses, and raw error messages
* @param rawMessage The raw error message from the API or exception
* @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
// Check for network/connection errors first (these are technical messages from exceptions)
for ((pattern, friendlyMessage) in networkErrorPatterns) {
if (trimmed.contains(pattern, ignoreCase = true)) {
return friendlyMessage
}
}
// If it's JSON, it's not meant for user display
// Try to parse and extract meaningful error info
// Check if it looks like a technical exception message
if (isTechnicalError(trimmed)) {
return "Something went wrong. Please try again."
}
// Check if the message looks like JSON (starts with { or [)
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
// Not JSON - if it's a short, readable message, return it
// Otherwise return a generic message
return if (isUserFriendly(trimmed)) trimmed else "Something went wrong. Please try again."
}
// If it's JSON, try to parse and extract meaningful error info
return try {
val jsonElement = Json.parseToJsonElement(trimmed)
@@ -51,4 +86,37 @@ object ErrorMessageParser {
"An error occurred. Please try again."
}
}
/**
* Checks if the message looks like a technical/developer error (stack trace, exception, etc)
*/
private fun isTechnicalError(message: String): Boolean {
val technicalIndicators = listOf(
"Exception",
"Error Domain=",
"UserInfo=",
"at com.",
"at org.",
"at java.",
"at kotlin.",
"at io.",
"Caused by:",
"Stack trace:",
".kt:",
".java:",
"0x",
"Code=",
"interface:",
"LocalDataTask"
)
return technicalIndicators.any { message.contains(it, ignoreCase = true) }
}
/**
* Checks if a message is user-friendly (short, no technical jargon)
*/
private fun isUserFriendly(message: String): Boolean {
// If it's short and doesn't contain technical indicators, it's probably user-friendly
return message.length < 200 && !isTechnicalError(message)
}
}

View File

@@ -126,7 +126,7 @@ class ContractorSharingManager: ObservableObject {
completion(false)
}
} catch {
self.importError = error.localizedDescription
self.importError = ErrorMessageParser.parse(error.localizedDescription)
self.isImporting = false
completion(false)
}

View File

@@ -57,7 +57,7 @@ class ContractorViewModel: ObservableObject {
self.isLoading = false
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
}
}
@@ -79,7 +79,7 @@ class ContractorViewModel: ObservableObject {
self.isLoading = false
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
}
}
@@ -104,7 +104,7 @@ class ContractorViewModel: ObservableObject {
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isCreating = false
completion(false)
}
@@ -130,7 +130,7 @@ class ContractorViewModel: ObservableObject {
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isUpdating = false
completion(false)
}
@@ -156,7 +156,7 @@ class ContractorViewModel: ObservableObject {
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isDeleting = false
completion(false)
}
@@ -176,7 +176,7 @@ class ContractorViewModel: ObservableObject {
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
completion(false)
}
}

View File

@@ -61,7 +61,7 @@ class DocumentViewModel: ObservableObject {
self.isLoading = false
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
}
}
@@ -135,7 +135,7 @@ class DocumentViewModel: ObservableObject {
completion(false, self.errorMessage)
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
completion(false, self.errorMessage)
}
@@ -203,7 +203,7 @@ class DocumentViewModel: ObservableObject {
completion(false, self.errorMessage)
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
completion(false, self.errorMessage)
}
@@ -228,7 +228,7 @@ class DocumentViewModel: ObservableObject {
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
completion(false)
}

View File

@@ -1,22 +1,82 @@
import Foundation
/// Utility for parsing and cleaning error messages from API responses
/// Utility for parsing and cleaning error messages from API responses and network errors
enum ErrorMessageParser {
// Network/connection error patterns to detect
private static let networkErrorPatterns: [(pattern: String, message: String)] = [
("Could not connect to the server", "Unable to connect to the server. Please check your internet connection."),
("NSURLErrorDomain", "Unable to connect to the server. Please check your internet connection."),
("The Internet connection appears to be offline", "No internet connection. Please check your network settings."),
("A server with the specified hostname could not be found", "Unable to connect to the server. Please check your internet connection."),
("The request timed out", "Request timed out. Please try again."),
("The network connection was lost", "Connection was interrupted. Please try again."),
("An SSL error has occurred", "Secure connection failed. Please try again."),
("CFNetwork", "Unable to connect to the server. Please check your internet connection."),
("kCFStreamError", "Unable to connect to the server. Please check your internet connection."),
("Code=-1004", "Unable to connect to the server. Please check your internet connection."),
("Code=-1009", "No internet connection. Please check your network settings."),
("Code=-1001", "Request timed out. Please try again."),
("Code=-1003", "Unable to connect to the server. Please check your internet connection."),
("Code=-1005", "Connection was interrupted. Please try again."),
("Code=-1200", "Secure connection failed. Please try again."),
("UnresolvedAddressException", "Unable to connect to the server. Please check your internet connection."),
("ConnectException", "Unable to connect to the server. Please check your internet connection."),
("SocketTimeoutException", "Request timed out. Please try again."),
("Connection refused", "Unable to connect to the server. The server may be down."),
("Connection reset", "Connection was interrupted. Please try again.")
]
// Indicators that a message is technical/developer-facing
private static let technicalIndicators = [
"Exception",
"Error Domain=",
"UserInfo=",
"at com.",
"at org.",
"at java.",
"at kotlin.",
"at io.",
"Caused by:",
"Stack trace:",
".kt:",
".java:",
".swift:",
"0x",
"Code=",
"interface:",
"LocalDataTask",
"NSUnderlyingError",
"_kCF"
]
/// 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
/// Handles network errors, JSON error responses, and raw error messages
/// - Parameter rawMessage: The raw error message from the API or exception
/// - 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
// Check for network/connection errors first (these are technical messages from exceptions)
for (pattern, friendlyMessage) in networkErrorPatterns {
if trimmed.localizedCaseInsensitiveContains(pattern) {
return friendlyMessage
}
}
// If it's JSON, it's not meant for user display
// Try to parse and extract meaningful error info
// Check if it looks like a technical exception message
if isTechnicalError(trimmed) {
return "Something went wrong. Please try again."
}
// Check if the message looks like JSON (starts with { or [)
guard trimmed.hasPrefix("{") || trimmed.hasPrefix("[") else {
// Not JSON - if it's a short, readable message, return it
// Otherwise return a generic message
return isUserFriendly(trimmed) ? trimmed : "Something went wrong. Please try again."
}
// If it's JSON, try to parse and extract meaningful error info
guard let data = trimmed.data(using: .utf8) else {
return "An error occurred. Please try again."
}
@@ -47,4 +107,15 @@ enum ErrorMessageParser {
// If we couldn't parse or extract a message, return a generic error
return "An error occurred. Please try again."
}
/// Checks if the message looks like a technical/developer error (stack trace, exception, etc)
private static func isTechnicalError(_ message: String) -> Bool {
return technicalIndicators.contains { message.localizedCaseInsensitiveContains($0) }
}
/// Checks if a message is user-friendly (short, no technical jargon)
private static func isUserFriendly(_ message: String) -> Bool {
// If it's short and doesn't contain technical indicators, it's probably user-friendly
return message.count < 200 && !isTechnicalError(message)
}
}

View File

@@ -65,7 +65,7 @@ class AppleSignInViewModel: ObservableObject {
}
} catch {
self.isLoading = false
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
}
}
}
@@ -115,7 +115,7 @@ class AppleSignInViewModel: ObservableObject {
}
errorMessage = appleError.errorDescription
} else {
errorMessage = error.localizedDescription
errorMessage = ErrorMessageParser.parse(error.localizedDescription)
}
}

View File

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

View File

@@ -97,7 +97,7 @@ class LoginViewModel: ObservableObject {
}
} catch {
self.isLoading = false
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
}
}
}

View File

@@ -69,7 +69,7 @@ class PasswordResetViewModel: ObservableObject {
}
} catch {
self.isLoading = false
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
}
}
}
@@ -106,7 +106,7 @@ class PasswordResetViewModel: ObservableObject {
}
} catch {
self.isLoading = false
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
}
}
}
@@ -155,7 +155,7 @@ class PasswordResetViewModel: ObservableObject {
}
} catch {
self.isLoading = false
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
}
}
}

View File

@@ -382,7 +382,7 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
self.isLoading = false
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
}
}
@@ -435,7 +435,7 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
self.isSaving = false
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isSaving = false
}
}

View File

@@ -71,7 +71,7 @@ class ProfileViewModel: ObservableObject {
self.isLoadingUser = false
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoadingUser = false
}
}
@@ -113,7 +113,7 @@ class ProfileViewModel: ObservableObject {
}
} catch {
self.isLoading = false
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.successMessage = nil
}
}

View File

@@ -76,7 +76,7 @@ class RegisterViewModel: ObservableObject {
self.isLoading = false
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
}
}

View File

@@ -112,7 +112,7 @@ struct ManageUsersView: View {
}
} catch {
await MainActor.run {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
}
}
@@ -162,7 +162,7 @@ struct ManageUsersView: View {
}
} catch {
await MainActor.run {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isGeneratingCode = false
}
}
@@ -188,7 +188,7 @@ struct ManageUsersView: View {
}
} catch {
await MainActor.run {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
}
}
}

View File

@@ -468,7 +468,7 @@ private extension ResidenceDetailView {
} catch {
await MainActor.run {
self.isDeleting = false
self.viewModel.errorMessage = error.localizedDescription
self.viewModel.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
}
}
}
@@ -500,7 +500,7 @@ private extension ResidenceDetailView {
}
} catch {
await MainActor.run {
self.contractorsError = error.localizedDescription
self.contractorsError = ErrorMessageParser.parse(error.localizedDescription)
self.isLoadingContractors = false
}
}

View File

@@ -151,7 +151,7 @@ class ResidenceSharingManager: ObservableObject {
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isImporting = false
completion(false)
}

View File

@@ -75,7 +75,7 @@ class ResidenceViewModel: ObservableObject {
}
self.isLoading = false
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
}
}
@@ -105,7 +105,7 @@ class ResidenceViewModel: ObservableObject {
self.isLoading = false
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
}
}
@@ -127,7 +127,7 @@ class ResidenceViewModel: ObservableObject {
self.isLoading = false
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
}
}
@@ -176,7 +176,7 @@ class ResidenceViewModel: ObservableObject {
} catch {
print("🏠 ResidenceVM: Exception: \(error)")
await MainActor.run {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
completion(nil)
}
@@ -205,7 +205,7 @@ class ResidenceViewModel: ObservableObject {
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
completion(false)
}
@@ -228,7 +228,7 @@ class ResidenceViewModel: ObservableObject {
self.isGeneratingReport = false
}
} catch {
self.reportMessage = error.localizedDescription
self.reportMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isGeneratingReport = false
}
}
@@ -263,7 +263,7 @@ class ResidenceViewModel: ObservableObject {
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
completion(false)
}

View File

@@ -85,7 +85,7 @@ class TaskViewModel: ObservableObject {
}
} catch {
self.actionState = .error(.create, error.localizedDescription)
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
completion(false)
}
}
@@ -112,7 +112,7 @@ class TaskViewModel: ObservableObject {
}
} catch {
self.actionState = .error(.cancel, error.localizedDescription)
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
completion(false)
}
}
@@ -136,7 +136,7 @@ class TaskViewModel: ObservableObject {
}
} catch {
self.actionState = .error(.uncancel, error.localizedDescription)
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
completion(false)
}
}
@@ -160,7 +160,7 @@ class TaskViewModel: ObservableObject {
}
} catch {
self.actionState = .error(.markInProgress, error.localizedDescription)
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
completion(false)
}
}
@@ -184,7 +184,7 @@ class TaskViewModel: ObservableObject {
}
} catch {
self.actionState = .error(.archive, error.localizedDescription)
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
completion(false)
}
}
@@ -208,7 +208,7 @@ class TaskViewModel: ObservableObject {
}
} catch {
self.actionState = .error(.unarchive, error.localizedDescription)
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
completion(false)
}
}
@@ -232,7 +232,7 @@ class TaskViewModel: ObservableObject {
}
} catch {
self.actionState = .error(.update, error.localizedDescription)
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
completion(false)
}
}
@@ -268,7 +268,7 @@ class TaskViewModel: ObservableObject {
self.isLoadingCompletions = false
}
} catch {
self.completionsError = error.localizedDescription
self.completionsError = ErrorMessageParser.parse(error.localizedDescription)
self.isLoadingCompletions = false
}
}
@@ -360,7 +360,7 @@ class TaskViewModel: ObservableObject {
}
} catch {
await MainActor.run {
self.tasksError = error.localizedDescription
self.tasksError = ErrorMessageParser.parse(error.localizedDescription)
self.isLoadingTasks = false
}
}

View File

@@ -57,7 +57,7 @@ class VerifyEmailViewModel: ObservableObject {
self.isLoading = false
}
} catch {
self.errorMessage = error.localizedDescription
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
}
}