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:
Trey t
2025-11-14 11:20:58 -06:00
parent c189971906
commit 0e3b9681f6
6 changed files with 176 additions and 29 deletions

View File

@@ -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)
}
}
)
}

View File

@@ -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
}
)
}
}

View 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))
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
}
)
}
}