Add error dialogs with retry/cancel for network failures
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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<String>()) }
|
||||
var showCompleteDialog by remember { mutableStateOf(false) }
|
||||
var selectedTask by remember { mutableStateOf<com.mycrib.shared.models.TaskDetail?>(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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
58
iosApp/iosApp/Helpers/ErrorAlertModifier.swift
Normal file
58
iosApp/iosApp/Helpers/ErrorAlertModifier.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user