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.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.mycrib.android.ui.components.CompleteTaskDialog
|
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.TaskCard
|
||||||
import com.mycrib.android.ui.components.task.TaskPill
|
import com.mycrib.android.ui.components.task.TaskPill
|
||||||
import com.mycrib.android.ui.utils.getIconFromName
|
import com.mycrib.android.ui.utils.getIconFromName
|
||||||
@@ -32,6 +33,16 @@ fun TasksScreen(
|
|||||||
var expandedColumns by remember { mutableStateOf(setOf<String>()) }
|
var expandedColumns by remember { mutableStateOf(setOf<String>()) }
|
||||||
var showCompleteDialog by remember { mutableStateOf(false) }
|
var showCompleteDialog by remember { mutableStateOf(false) }
|
||||||
var selectedTask by remember { mutableStateOf<com.mycrib.shared.models.TaskDetail?>(null) }
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.loadTasks()
|
viewModel.loadTasks()
|
||||||
@@ -64,7 +75,7 @@ fun TasksScreen(
|
|||||||
// No FAB on Tasks screen - tasks are added from within residences
|
// No FAB on Tasks screen - tasks are added from within residences
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
when (tasksState) {
|
when (tasksState) {
|
||||||
is ApiResult.Idle, is ApiResult.Loading -> {
|
is ApiResult.Idle, is ApiResult.Loading, is ApiResult.Error -> {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -74,25 +85,6 @@ fun TasksScreen(
|
|||||||
CircularProgressIndicator()
|
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 -> {
|
is ApiResult.Success -> {
|
||||||
val taskData = (tasksState as ApiResult.Success).data
|
val taskData = (tasksState as ApiResult.Success).data
|
||||||
val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
|
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,
|
residence: 1,
|
||||||
title: "Clean Gutters",
|
title: "Clean Gutters",
|
||||||
description: "Remove all debris from gutters",
|
description: "Remove all debris from gutters",
|
||||||
category: TaskCategory(id: 1, name: "maintenance", description: ""),
|
category: TaskCategory(id: 1, name: "maintenance", orderId: 0, description: ""),
|
||||||
priority: TaskPriority(id: 2, name: "medium", displayName: "", description: ""),
|
priority: TaskPriority(id: 2, name: "medium", displayName: "", orderId: 0, description: ""),
|
||||||
frequency: TaskFrequency(id: 1, name: "monthly", lookupName: "", displayName: "30", daySpan: 0, notifyDays: 0),
|
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",
|
dueDate: "2024-12-15",
|
||||||
estimatedCost: "150.00",
|
estimatedCost: "150.00",
|
||||||
actualCost: nil,
|
actualCost: nil,
|
||||||
|
|||||||
@@ -83,10 +83,10 @@ struct TasksSection: View {
|
|||||||
residence: 1,
|
residence: 1,
|
||||||
title: "Clean Gutters",
|
title: "Clean Gutters",
|
||||||
description: "Remove all debris",
|
description: "Remove all debris",
|
||||||
category: TaskCategory(id: 1, name: "maintenance", description: ""),
|
category: TaskCategory(id: 1, name: "maintenance", orderId: 1, description: ""),
|
||||||
priority: TaskPriority(id: 2, name: "medium", displayName: "Medium", 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),
|
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",
|
dueDate: "2024-12-15",
|
||||||
estimatedCost: "150.00",
|
estimatedCost: "150.00",
|
||||||
actualCost: nil,
|
actualCost: nil,
|
||||||
@@ -113,10 +113,10 @@ struct TasksSection: View {
|
|||||||
residence: 1,
|
residence: 1,
|
||||||
title: "Fix Leaky Faucet",
|
title: "Fix Leaky Faucet",
|
||||||
description: "Kitchen sink fixed",
|
description: "Kitchen sink fixed",
|
||||||
category: TaskCategory(id: 2, name: "plumbing", description: ""),
|
category: TaskCategory(id: 2, name: "plumbing", orderId: 1, description: ""),
|
||||||
priority: TaskPriority(id: 3, name: "high", displayName: "High", 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),
|
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",
|
dueDate: "2024-11-01",
|
||||||
estimatedCost: "200.00",
|
estimatedCost: "200.00",
|
||||||
actualCost: nil,
|
actualCost: nil,
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ struct TaskFormView: View {
|
|||||||
@State private var titleError: String = ""
|
@State private var titleError: String = ""
|
||||||
@State private var residenceError: String = ""
|
@State private var residenceError: String = ""
|
||||||
|
|
||||||
|
// Error alert state
|
||||||
|
@State private var errorAlert: ErrorAlertInfo? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -173,6 +176,21 @@ struct TaskFormView: View {
|
|||||||
isPresented = false
|
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