diff --git a/ERROR_HANDLING.md b/ERROR_HANDLING.md index d027bbd..7f371a4 100644 --- a/ERROR_HANDLING.md +++ b/ERROR_HANDLING.md @@ -1,112 +1,40 @@ -# Error Handling in MyCrib Apps +# Error Handling Guide -Both iOS and Android apps now display detailed error messages from the API. +This guide explains how to implement consistent error handling with retry/cancel dialogs across Android and iOS apps. -## How It Works +## Android (Compose) -### Backend (Django) -The Django backend returns errors in a standardized JSON format: +### Components Available -```json -{ - "error": "Error Type", - "detail": "Detailed error message", - "status_code": 400, - "errors": { - "field_name": ["Field-specific error messages"] - } -} -``` +1. **ErrorDialog** - Reusable Material3 AlertDialog with retry/cancel buttons +2. **ApiResultHandler** - Composable that automatically handles ApiResult states +3. **HandleErrors()** - Extension function for ApiResult states -### Shared Network Layer (Kotlin Multiplatform) +### Usage Examples -#### ErrorResponse Model -Located at: `composeApp/src/commonMain/kotlin/com/example/mycrib/models/ErrorResponse.kt` +See full documentation in the file for complete examples of: +- Using ApiResultHandler for data loading screens +- Using HandleErrors() extension for create/update/delete operations +- Using ErrorDialog directly for custom scenarios -Defines the structure of error responses from the API. +## iOS (SwiftUI) -#### ErrorParser -Located at: `composeApp/src/commonMain/kotlin/com/example/mycrib/network/ErrorParser.kt` +### Components Available -Parses error responses and extracts detailed messages: -- Primary error detail from the `detail` field -- Field-specific errors from the `errors` field (if present) -- Fallback to plain text or generic message if parsing fails +1. **ErrorAlertModifier** - View modifier that shows error alerts +2. **ViewStateHandler** - View that handles loading/error/success states +3. **handleErrors()** - View extension for automatic error monitoring -#### Updated API Calls -All TaskApi methods now use ErrorParser to extract detailed error messages: -- `getTasks()` -- `getTask()` -- `createTask()` -- `updateTask()` -- `deleteTask()` -- `getTasksByResidence()` -- `cancelTask()` -- `uncancelTask()` -- `markInProgress()` -- `archiveTask()` -- `unarchiveTask()` +### Usage Examples -### Android +See full documentation for examples of each approach. -Android automatically receives parsed error messages through the shared `ApiResult.Error` class. ViewModels and UI components display these messages to users. +## Files Reference -**Example:** -```kotlin -when (result) { - is ApiResult.Error -> { - // result.message contains the detailed error from ErrorParser - showError(result.message) - } -} -``` +**Android:** +- `composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ErrorDialog.kt` +- `composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ApiResultHandler.kt` -### iOS - -iOS receives parsed error messages through the `ApiResultError` wrapper class from the shared KMM module. ViewModels extract and display these messages. - -**Example:** -```swift -if let errorResult = result as? ApiResultError { - self.errorMessage = errorResult.message // Contains detailed error from ErrorParser -} -``` - -## Error Message Format - -### Simple Errors -``` -Invalid task status -``` - -### Validation Errors with Field Details -``` -Invalid data provided - -Details: -• email: This field is required. -• password: Password must be at least 8 characters. -``` - -## Benefits - -1. **User-Friendly**: Users see specific error messages instead of generic "Failed to..." messages -2. **Debugging**: Developers can quickly identify issues from error messages -3. **Consistent**: Both platforms display the same detailed errors -4. **Maintainable**: Single source of truth for error parsing in shared code -5. **Backend-Driven**: Error messages are controlled by the Django backend - -## Testing - -To test error handling: - -1. Create a task with invalid data -2. Update a task with missing required fields -3. Try to perform actions without authentication -4. Observe the detailed error messages displayed in the UI - -## Future Improvements - -- Add error codes for programmatic handling -- Implement retry logic for specific error types -- Add localization support for error messages +**iOS:** +- `iosApp/iosApp/Helpers/ErrorAlertModifier.swift` +- `iosApp/iosApp/Helpers/ViewStateHandler.swift` diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ApiResultHandler.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ApiResultHandler.kt new file mode 100644 index 0000000..a02b074 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ApiResultHandler.kt @@ -0,0 +1,146 @@ +package com.mycrib.android.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.mycrib.shared.network.ApiResult + +/** + * Handles ApiResult states automatically with loading, error dialogs, and success content. + * + * Example usage: + * ``` + * val state by viewModel.dataState.collectAsState() + * + * ApiResultHandler( + * state = state, + * onRetry = { viewModel.loadData() } + * ) { data -> + * // Success content using the data + * Text("Data: ${data.name}") + * } + * ``` + * + * @param T The type of data in the ApiResult.Success + * @param state The current ApiResult state + * @param onRetry Callback to retry the operation when error occurs + * @param modifier Modifier for the container + * @param loadingContent Custom loading content (default: CircularProgressIndicator) + * @param errorTitle Custom error dialog title (default: "Network Error") + * @param content Content to show when state is Success + */ +@Composable +fun ApiResultHandler( + state: ApiResult, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + loadingContent: @Composable (() -> Unit)? = null, + errorTitle: String = "Network Error", + content: @Composable (T) -> Unit +) { + var showErrorDialog by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + + // Show error dialog when state changes to Error + LaunchedEffect(state) { + if (state is ApiResult.Error) { + errorMessage = state.message + showErrorDialog = true + } + } + + when (state) { + is ApiResult.Idle, is ApiResult.Loading -> { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (loadingContent != null) { + loadingContent() + } else { + CircularProgressIndicator() + } + } + } + is ApiResult.Error -> { + // Show loading indicator while error dialog is displayed + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + is ApiResult.Success -> { + content(state.data) + } + } + + // Error dialog + if (showErrorDialog) { + ErrorDialog( + title = errorTitle, + message = errorMessage, + onRetry = { + showErrorDialog = false + onRetry() + }, + onDismiss = { + showErrorDialog = false + } + ) + } +} + +/** + * Extension function to observe ApiResult state and show error dialog + * Use this for operations that don't return data to display (like create/update/delete) + * + * Example usage: + * ``` + * val createState by viewModel.createState.collectAsState() + * + * createState.HandleErrors( + * onRetry = { viewModel.createItem() } + * ) + * + * LaunchedEffect(createState) { + * if (createState is ApiResult.Success) { + * // Handle success + * navController.popBackStack() + * } + * } + * ``` + */ +@Composable +fun ApiResult.HandleErrors( + onRetry: () -> Unit, + errorTitle: String = "Network Error" +) { + var showErrorDialog by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + + LaunchedEffect(this) { + if (this@HandleErrors is ApiResult.Error) { + errorMessage = (this@HandleErrors as ApiResult.Error).message + showErrorDialog = true + } + } + + if (showErrorDialog) { + ErrorDialog( + title = errorTitle, + message = errorMessage, + onRetry = { + showErrorDialog = false + onRetry() + }, + onDismiss = { + showErrorDialog = false + } + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt index 0ec255c..1337430 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.mycrib.android.ui.components.ApiResultHandler import com.mycrib.android.ui.components.JoinResidenceDialog import com.mycrib.android.ui.components.common.StatItem import com.mycrib.android.ui.components.residence.TaskStatChip diff --git a/iosApp/iosApp/Helpers/ViewStateHandler.swift b/iosApp/iosApp/Helpers/ViewStateHandler.swift new file mode 100644 index 0000000..252b078 --- /dev/null +++ b/iosApp/iosApp/Helpers/ViewStateHandler.swift @@ -0,0 +1,112 @@ +import SwiftUI + +/// A view that handles loading, error, and success states with automatic error alerts +/// +/// Example usage: +/// ```swift +/// ViewStateHandler( +/// isLoading: viewModel.isLoading, +/// error: viewModel.errorMessage, +/// onRetry: { viewModel.loadData() } +/// ) { +/// // Success content +/// List(items) { item in +/// Text(item.name) +/// } +/// } +/// ``` +struct ViewStateHandler: View { + let isLoading: Bool + let error: String? + let onRetry: () -> Void + let content: Content + + @State private var errorAlert: ErrorAlertInfo? = nil + + init( + isLoading: Bool, + error: String?, + onRetry: @escaping () -> Void, + @ViewBuilder content: () -> Content + ) { + self.isLoading = isLoading + self.error = error + self.onRetry = onRetry + self.content = content() + } + + var body: some View { + ZStack { + if isLoading { + ProgressView() + .scaleEffect(1.5) + } else { + content + } + } + .onChange(of: error) { errorMessage in + if let errorMessage = errorMessage, !errorMessage.isEmpty { + errorAlert = ErrorAlertInfo(message: errorMessage) + } + } + .errorAlert( + error: errorAlert, + onRetry: { + errorAlert = nil + onRetry() + }, + onDismiss: { + errorAlert = nil + } + ) + } +} + +/// Extension to add automatic error handling to any view +extension View { + /// Monitors an error message and shows error alert when it changes + /// + /// Example usage: + /// ```swift + /// Form { + /// // Form fields + /// } + /// .handleErrors( + /// error: viewModel.errorMessage, + /// onRetry: { viewModel.submitForm() } + /// ) + /// ``` + func handleErrors( + error: String?, + onRetry: @escaping () -> Void + ) -> some View { + modifier(ErrorHandlerModifier(error: error, onRetry: onRetry)) + } +} + +/// View modifier that handles errors automatically +private struct ErrorHandlerModifier: ViewModifier { + let error: String? + let onRetry: () -> Void + + @State private var errorAlert: ErrorAlertInfo? = nil + + func body(content: Content) -> some View { + content + .onChange(of: error) { errorMessage in + if let errorMessage = errorMessage, !errorMessage.isEmpty { + errorAlert = ErrorAlertInfo(message: errorMessage) + } + } + .errorAlert( + error: errorAlert, + onRetry: { + errorAlert = nil + onRetry() + }, + onDismiss: { + errorAlert = nil + } + ) + } +}