feat: Add error handling utilities, NetworkService, RetryHelper, and OfflineIndicator

This commit is contained in:
Patrick Britton 2026-02-17 10:22:07 -06:00
parent d403749092
commit 5f7dfa7a93
4 changed files with 231 additions and 0 deletions

View file

@ -0,0 +1,41 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
/// Service for monitoring network connectivity status
class NetworkService {
static final NetworkService _instance = NetworkService._internal();
factory NetworkService() => _instance;
NetworkService._internal();
final Connectivity _connectivity = Connectivity();
final StreamController<bool> _connectionController =
StreamController<bool>.broadcast();
Stream<bool> get connectionStream => _connectionController.stream;
bool _isConnected = true;
bool get isConnected => _isConnected;
/// Initialize the network service and start monitoring
void initialize() {
_connectivity.onConnectivityChanged.listen((List<ConnectivityResult> results) {
final result = results.isNotEmpty ? results.first : ConnectivityResult.none;
_isConnected = result != ConnectivityResult.none;
_connectionController.add(_isConnected);
});
// Check initial state
_checkConnection();
}
Future<void> _checkConnection() async {
final results = await _connectivity.checkConnectivity();
final result = results.isNotEmpty ? results.first : ConnectivityResult.none;
_isConnected = result != ConnectivityResult.none;
_connectionController.add(_isConnected);
}
void dispose() {
_connectionController.close();
}
}

View file

@ -0,0 +1,87 @@
import 'dart:io';
import 'dart:async';
import 'package:flutter/material.dart';
/// Global error handler for consistent error messaging and logging
class ErrorHandler {
/// Handle an error and optionally show a snackbar to the user
static void handleError(
dynamic error, {
required BuildContext context,
String? userMessage,
bool showSnackbar = true,
}) {
final displayMessage = _getDisplayMessage(error, userMessage);
// Log to console (in production, send to analytics/crash reporting)
_logError(error, displayMessage);
if (showSnackbar && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(displayMessage),
action: SnackBarAction(
label: 'Dismiss',
onPressed: () {},
),
duration: const Duration(seconds: 4),
backgroundColor: Colors.red[700],
),
);
}
}
/// Get user-friendly error message
static String _getDisplayMessage(dynamic error, String? userMessage) {
if (userMessage != null) return userMessage;
if (error is SocketException) {
return 'No internet connection. Please check your network.';
} else if (error is TimeoutException) {
return 'Request timed out. Please try again.';
} else if (error is FormatException) {
return 'Invalid data format received.';
} else if (error.toString().contains('401')) {
return 'Authentication error. Please sign in again.';
} else if (error.toString().contains('403')) {
return 'You don\'t have permission to do that.';
} else if (error.toString().contains('404')) {
return 'Resource not found.';
} else if (error.toString().contains('500')) {
return 'Server error. Please try again later.';
} else {
return 'Something went wrong. Please try again.';
}
}
/// Log error for debugging/analytics
static void _logError(dynamic error, String message) {
// In production, send to Sentry, Firebase Crashlytics, etc.
debugPrint('ERROR: $message');
debugPrint('Details: ${error.toString()}');
if (error is Error) {
debugPrint('Stack trace: ${error.stackTrace}');
}
}
}
/// Wrapper for async operations with automatic error handling
Future<T?> safeExecute<T>({
required Future<T> Function() operation,
required BuildContext context,
String? errorMessage,
bool showError = true,
}) async {
try {
return await operation();
} catch (e) {
if (showError) {
ErrorHandler.handleError(
e,
context: context,
userMessage: errorMessage,
);
}
return null;
}
}

View file

@ -0,0 +1,60 @@
import 'dart:async';
/// Helper for retrying failed operations with exponential backoff
class RetryHelper {
/// Retry an operation with exponential backoff
static Future<T> retry<T>({
required Future<T> Function() operation,
int maxAttempts = 3,
Duration initialDelay = const Duration(seconds: 1),
double backoffMultiplier = 2.0,
bool Function(dynamic error)? retryIf,
}) async {
int attempt = 0;
Duration delay = initialDelay;
while (true) {
try {
return await operation();
} catch (e) {
attempt++;
// Check if we should retry this error
if (retryIf != null && !retryIf(e)) {
rethrow;
}
if (attempt >= maxAttempts) {
rethrow; // Give up after max attempts
}
// Wait before retrying with exponential backoff
await Future.delayed(delay);
delay = Duration(
milliseconds: (delay.inMilliseconds * backoffMultiplier).round(),
);
}
}
}
/// Retry specifically for network operations
static Future<T> retryNetwork<T>({
required Future<T> Function() operation,
int maxAttempts = 3,
}) async {
return retry(
operation: operation,
maxAttempts: maxAttempts,
retryIf: (error) {
// Retry on network errors, timeouts, and 5xx server errors
final errorStr = error.toString().toLowerCase();
return errorStr.contains('socket') ||
errorStr.contains('timeout') ||
errorStr.contains('500') ||
errorStr.contains('502') ||
errorStr.contains('503') ||
errorStr.contains('504');
},
);
}
}

View file

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import '../services/network_service.dart';
/// Banner that appears at top of screen when offline
class OfflineIndicator extends StatelessWidget {
const OfflineIndicator({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<bool>(
stream: NetworkService().connectionStream,
initialData: NetworkService().isConnected,
builder: (context, snapshot) {
final isConnected = snapshot.data ?? true;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: isConnected ? 0 : 30,
color: Colors.orange[700],
child: !isConnected
? Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.wifi_off, size: 16, color: Colors.white),
const SizedBox(width: 8),
Text(
'No internet connection',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
)
: null,
);
},
);
}
}