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 { 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 * Parses error messages to extract user-friendly text
* If the error message is JSON, extract relevant error details * Handles network errors, JSON error responses, and raw error messages
* @param rawMessage The raw error message from the API * @param rawMessage The raw error message from the API or exception
* @return A user-friendly error message * @return A user-friendly error message
*/ */
fun parse(rawMessage: String): String { fun parse(rawMessage: String): String {
val trimmed = rawMessage.trim() val trimmed = rawMessage.trim()
// Check if the message looks like JSON (starts with { or [) // Check for network/connection errors first (these are technical messages from exceptions)
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { for ((pattern, friendlyMessage) in networkErrorPatterns) {
// Not JSON, return as-is if (trimmed.contains(pattern, ignoreCase = true)) {
return rawMessage return friendlyMessage
}
} }
// If it's JSON, it's not meant for user display // Check if it looks like a technical exception message
// Try to parse and extract meaningful error info 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 { return try {
val jsonElement = Json.parseToJsonElement(trimmed) val jsonElement = Json.parseToJsonElement(trimmed)
@@ -51,4 +86,37 @@ object ErrorMessageParser {
"An error occurred. Please try again." "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) completion(false)
} }
} catch { } catch {
self.importError = error.localizedDescription self.importError = ErrorMessageParser.parse(error.localizedDescription)
self.isImporting = false self.isImporting = false
completion(false) completion(false)
} }

View File

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

View File

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

View File

@@ -1,22 +1,82 @@
import Foundation 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 { 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 /// Parses error messages to extract user-friendly text
/// If the error message is JSON, extract relevant error details /// Handles network errors, JSON error responses, and raw error messages
/// - Parameter rawMessage: The raw error message from the API /// - Parameter rawMessage: The raw error message from the API or exception
/// - Returns: A user-friendly error message /// - Returns: A user-friendly error message
static func parse(_ rawMessage: String) -> String { static func parse(_ rawMessage: String) -> String {
// Check if the message looks like JSON (starts with { or [)
let trimmed = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("{") || trimmed.hasPrefix("[") else {
// Not JSON, return as-is // Check for network/connection errors first (these are technical messages from exceptions)
return rawMessage for (pattern, friendlyMessage) in networkErrorPatterns {
if trimmed.localizedCaseInsensitiveContains(pattern) {
return friendlyMessage
}
} }
// If it's JSON, it's not meant for user display // Check if it looks like a technical exception message
// Try to parse and extract meaningful error info 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 { guard let data = trimmed.data(using: .utf8) else {
return "An error occurred. Please try again." 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 // If we couldn't parse or extract a message, return a generic error
return "An error occurred. Please try again." 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 { } catch {
self.isLoading = false self.isLoading = false
self.errorMessage = error.localizedDescription self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
} }
} }
} }
@@ -115,7 +115,7 @@ class AppleSignInViewModel: ObservableObject {
} }
errorMessage = appleError.errorDescription errorMessage = appleError.errorDescription
} else { } else {
errorMessage = error.localizedDescription errorMessage = ErrorMessageParser.parse(error.localizedDescription)
} }
} }

View File

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

View File

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

View File

@@ -69,7 +69,7 @@ class PasswordResetViewModel: ObservableObject {
} }
} catch { } catch {
self.isLoading = false self.isLoading = false
self.errorMessage = error.localizedDescription self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
} }
} }
} }
@@ -106,7 +106,7 @@ class PasswordResetViewModel: ObservableObject {
} }
} catch { } catch {
self.isLoading = false self.isLoading = false
self.errorMessage = error.localizedDescription self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
} }
} }
} }
@@ -155,7 +155,7 @@ class PasswordResetViewModel: ObservableObject {
} }
} catch { } catch {
self.isLoading = false 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 self.isLoading = false
} }
} catch { } catch {
self.errorMessage = error.localizedDescription self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false self.isLoading = false
} }
} }
@@ -435,7 +435,7 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
self.isSaving = false self.isSaving = false
} }
} catch { } catch {
self.errorMessage = error.localizedDescription self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isSaving = false self.isSaving = false
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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