From 0e3b9681f67e39b7bf813c917c7a7a9441cfa44d Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 14 Nov 2025 11:20:58 -0600 Subject: [PATCH] Add error dialogs with retry/cancel for network failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented user-friendly error handling for network call failures: Android (Compose): - Created reusable ErrorDialog component with retry and cancel buttons - Updated TasksScreen to show error dialog popup instead of inline error - Error dialog appears when network calls fail with option to retry iOS (SwiftUI): - Created ErrorAlertModifier and ErrorAlertInfo helpers - Added .errorAlert() view modifier for consistent error handling - Updated TaskFormView to show error alerts with retry/cancel options - Error alerts appear when task creation or other network calls fail Both platforms now provide a consistent user experience when network errors occur, giving users the choice to retry the operation or cancel. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../mycrib/ui/components/ErrorDialog.kt | 65 +++++++++++++++++++ .../example/mycrib/ui/screens/TasksScreen.kt | 46 +++++++------ .../iosApp/Helpers/ErrorAlertModifier.swift | 58 +++++++++++++++++ iosApp/iosApp/Subviews/Task/TaskCard.swift | 6 +- .../iosApp/Subviews/Task/TasksSection.swift | 12 ++-- iosApp/iosApp/Task/TaskFormView.swift | 18 +++++ 6 files changed, 176 insertions(+), 29 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ErrorDialog.kt create mode 100644 iosApp/iosApp/Helpers/ErrorAlertModifier.swift diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ErrorDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ErrorDialog.kt new file mode 100644 index 0000000..15c125a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ErrorDialog.kt @@ -0,0 +1,65 @@ +package com.mycrib.android.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** + * Reusable error dialog component that shows network errors with retry/cancel options + * + * @param title Dialog title (default: "Network Error") + * @param message Error message to display + * @param onRetry Callback when user clicks Retry button + * @param onDismiss Callback when user clicks Cancel or dismisses dialog + * @param retryButtonText Text for retry button (default: "Try Again") + * @param dismissButtonText Text for dismiss button (default: "Cancel") + */ +@Composable +fun ErrorDialog( + title: String = "Network Error", + message: String, + onRetry: () -> Unit, + onDismiss: () -> Unit, + retryButtonText: String = "Try Again", + dismissButtonText: String = "Cancel" +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.error + ) + }, + text = { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button( + onClick = { + onDismiss() + onRetry() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text(retryButtonText) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(dismissButtonText) + } + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt index 2609e44..d3af6f8 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.mycrib.android.ui.components.CompleteTaskDialog +import com.mycrib.android.ui.components.ErrorDialog import com.mycrib.android.ui.components.task.TaskCard import com.mycrib.android.ui.components.task.TaskPill import com.mycrib.android.ui.utils.getIconFromName @@ -32,6 +33,16 @@ fun TasksScreen( var expandedColumns by remember { mutableStateOf(setOf()) } var showCompleteDialog by remember { mutableStateOf(false) } var selectedTask by remember { mutableStateOf(null) } + var showErrorDialog by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + + // Show error dialog when tasks fail to load + LaunchedEffect(tasksState) { + if (tasksState is ApiResult.Error) { + errorMessage = (tasksState as ApiResult.Error).message + showErrorDialog = true + } + } LaunchedEffect(Unit) { viewModel.loadTasks() @@ -64,7 +75,7 @@ fun TasksScreen( // No FAB on Tasks screen - tasks are added from within residences ) { paddingValues -> when (tasksState) { - is ApiResult.Idle, is ApiResult.Loading -> { + is ApiResult.Idle, is ApiResult.Loading, is ApiResult.Error -> { Box( modifier = Modifier .fillMaxSize() @@ -74,25 +85,6 @@ fun TasksScreen( CircularProgressIndicator() } } - is ApiResult.Error -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - contentAlignment = androidx.compose.ui.Alignment.Center - ) { - Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) { - Text( - text = "Error: ${(tasksState as ApiResult.Error).message}", - color = MaterialTheme.colorScheme.error - ) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { viewModel.loadTasks() }) { - Text("Retry") - } - } - } - } is ApiResult.Success -> { val taskData = (tasksState as ApiResult.Success).data val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() } @@ -276,4 +268,18 @@ fun TasksScreen( } ) } + + // Show error dialog when network call fails + if (showErrorDialog) { + ErrorDialog( + message = errorMessage, + onRetry = { + showErrorDialog = false + viewModel.loadTasks() + }, + onDismiss = { + showErrorDialog = false + } + ) + } } diff --git a/iosApp/iosApp/Helpers/ErrorAlertModifier.swift b/iosApp/iosApp/Helpers/ErrorAlertModifier.swift new file mode 100644 index 0000000..240ab5c --- /dev/null +++ b/iosApp/iosApp/Helpers/ErrorAlertModifier.swift @@ -0,0 +1,58 @@ +import SwiftUI + +/// A view modifier that shows error alerts with retry/cancel options +struct ErrorAlertModifier: ViewModifier { + let error: ErrorAlertInfo? + let onRetry: () -> Void + let onDismiss: () -> Void + + func body(content: Content) -> some View { + content + .alert( + error?.title ?? "Network Error", + isPresented: Binding( + get: { error != nil }, + set: { if !$0 { onDismiss() } } + ) + ) { + Button("Try Again", role: .none) { + onRetry() + } + Button("Cancel", role: .cancel) { + onDismiss() + } + } message: { + if let error = error { + Text(error.message) + } + } + } +} + +/// Information about an error to display in an alert +struct ErrorAlertInfo: Identifiable { + let id = UUID() + let title: String + let message: String + + init(title: String = "Network Error", message: String) { + self.title = title + self.message = message + } +} + +extension View { + /// Shows an error alert with retry and cancel buttons + /// + /// - Parameters: + /// - error: The error to display, or nil to hide the alert + /// - onRetry: Closure called when user taps "Try Again" + /// - onDismiss: Closure called when user taps "Cancel" or dismisses alert + func errorAlert( + error: ErrorAlertInfo?, + onRetry: @escaping () -> Void, + onDismiss: @escaping () -> Void + ) -> some View { + modifier(ErrorAlertModifier(error: error, onRetry: onRetry, onDismiss: onDismiss)) + } +} diff --git a/iosApp/iosApp/Subviews/Task/TaskCard.swift b/iosApp/iosApp/Subviews/Task/TaskCard.swift index 8d51b1d..4d08477 100644 --- a/iosApp/iosApp/Subviews/Task/TaskCard.swift +++ b/iosApp/iosApp/Subviews/Task/TaskCard.swift @@ -260,10 +260,10 @@ struct TaskCard: View { residence: 1, title: "Clean Gutters", description: "Remove all debris from gutters", - category: TaskCategory(id: 1, name: "maintenance", description: ""), - priority: TaskPriority(id: 2, name: "medium", displayName: "", description: ""), + category: TaskCategory(id: 1, name: "maintenance", orderId: 0, description: ""), + priority: TaskPriority(id: 2, name: "medium", displayName: "", orderId: 0, description: ""), frequency: TaskFrequency(id: 1, name: "monthly", lookupName: "", displayName: "30", daySpan: 0, notifyDays: 0), - status: TaskStatus(id: 1, name: "pending", displayName: "", description: ""), + status: TaskStatus(id: 1, name: "pending", displayName: "", orderId: 0, description: ""), dueDate: "2024-12-15", estimatedCost: "150.00", actualCost: nil, diff --git a/iosApp/iosApp/Subviews/Task/TasksSection.swift b/iosApp/iosApp/Subviews/Task/TasksSection.swift index 74f4246..7ccf423 100644 --- a/iosApp/iosApp/Subviews/Task/TasksSection.swift +++ b/iosApp/iosApp/Subviews/Task/TasksSection.swift @@ -83,10 +83,10 @@ struct TasksSection: View { residence: 1, title: "Clean Gutters", description: "Remove all debris", - category: TaskCategory(id: 1, name: "maintenance", description: ""), - priority: TaskPriority(id: 2, name: "medium", displayName: "Medium", description: ""), + category: TaskCategory(id: 1, name: "maintenance", orderId: 1, description: ""), + priority: TaskPriority(id: 2, name: "medium", displayName: "Medium", orderId: 1, description: ""), frequency: TaskFrequency(id: 1, name: "monthly", lookupName: "", displayName: "Monthly", daySpan: 0, notifyDays: 0), - status: TaskStatus(id: 1, name: "pending", displayName: "Pending", description: ""), + status: TaskStatus(id: 1, name: "pending", displayName: "Pending", orderId: 1, description: ""), dueDate: "2024-12-15", estimatedCost: "150.00", actualCost: nil, @@ -113,10 +113,10 @@ struct TasksSection: View { residence: 1, title: "Fix Leaky Faucet", description: "Kitchen sink fixed", - category: TaskCategory(id: 2, name: "plumbing", description: ""), - priority: TaskPriority(id: 3, name: "high", displayName: "High", description: ""), + category: TaskCategory(id: 2, name: "plumbing", orderId: 1, description: ""), + priority: TaskPriority(id: 3, name: "high", displayName: "High", orderId: 1, description: ""), frequency: TaskFrequency(id: 6, name: "once", lookupName: "", displayName: "One Time", daySpan: 0, notifyDays: 0), - status: TaskStatus(id: 3, name: "completed", displayName: "Completed", description: ""), + status: TaskStatus(id: 3, name: "completed", displayName: "Completed", orderId: 1, description: ""), dueDate: "2024-11-01", estimatedCost: "200.00", actualCost: nil, diff --git a/iosApp/iosApp/Task/TaskFormView.swift b/iosApp/iosApp/Task/TaskFormView.swift index bc867dd..af6a395 100644 --- a/iosApp/iosApp/Task/TaskFormView.swift +++ b/iosApp/iosApp/Task/TaskFormView.swift @@ -41,6 +41,9 @@ struct TaskFormView: View { @State private var titleError: String = "" @State private var residenceError: String = "" + // Error alert state + @State private var errorAlert: ErrorAlertInfo? = nil + var body: some View { NavigationStack { ZStack { @@ -173,6 +176,21 @@ struct TaskFormView: View { isPresented = false } } + .onChange(of: viewModel.errorMessage) { errorMessage in + if let errorMessage = errorMessage, !errorMessage.isEmpty { + errorAlert = ErrorAlertInfo(message: errorMessage) + } + } + .errorAlert( + error: errorAlert, + onRetry: { + errorAlert = nil + submitForm() + }, + onDismiss: { + errorAlert = nil + } + ) } }