feat(mobile): amélioration UX NotImplementedFailure + SnackbarHelper
- NotImplementedFailure: ajout userFriendlyMessage et icon construction (blue) - ErrorDisplayWidget: support spécial pour NotImplementedFailure (bientôt disponible) - SnackbarHelper: classe centralisée pour messages cohérents (success, error, warning, info, notImplemented) - budgets_list_page: remplace generic snackbar par SnackbarHelper.showNotImplemented - conversations_page: remplace 2 TODOs par SnackbarHelper.showNotImplemented - export_members: met à jour TODO obsolète (endpoint PDF maintenant disponible) - cache_service: fix AppLogger.error calls (error: named param) - cached_datasource_decorator: fix AppLogger.error call Task #64 - Fix Snackbar Placeholders + NotImplementedFailure UX
This commit is contained in:
12
lib/core/cache/cache_service.dart
vendored
12
lib/core/cache/cache_service.dart
vendored
@@ -35,7 +35,7 @@ class CacheService {
|
|||||||
|
|
||||||
return success;
|
return success;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
AppLogger.error('Erreur lors de la mise en cache de $key', e);
|
AppLogger.error('Erreur lors de la mise en cache de $key', error: e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ class CacheService {
|
|||||||
AppLogger.debug('Cache hit: $key');
|
AppLogger.debug('Cache hit: $key');
|
||||||
return value as T;
|
return value as T;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
AppLogger.error('Erreur lors de la lecture du cache $key', e);
|
AppLogger.error('Erreur lors de la lecture du cache $key', error: e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ class CacheService {
|
|||||||
try {
|
try {
|
||||||
return await _prefs.remove(key);
|
return await _prefs.remove(key);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
AppLogger.error('Erreur lors de la suppression du cache $key', e);
|
AppLogger.error('Erreur lors de la suppression du cache $key', error: e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ class CacheService {
|
|||||||
|
|
||||||
AppLogger.info('Cache nettoyé pour préfixe: $prefix');
|
AppLogger.info('Cache nettoyé pour préfixe: $prefix');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
AppLogger.error('Erreur lors du nettoyage du cache $prefix', e);
|
AppLogger.error('Erreur lors du nettoyage du cache $prefix', error: e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ class CacheService {
|
|||||||
try {
|
try {
|
||||||
return await _prefs.clear();
|
return await _prefs.clear();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
AppLogger.error('Erreur lors du nettoyage complet du cache', e);
|
AppLogger.error('Erreur lors du nettoyage complet du cache', error: e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ class CacheService {
|
|||||||
AppLogger.info('$cleaned entrées de cache expirées nettoyées');
|
AppLogger.info('$cleaned entrées de cache expirées nettoyées');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
AppLogger.error('Erreur lors du nettoyage des caches expirés', e);
|
AppLogger.error('Erreur lors du nettoyage des caches expirés', error: e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class CachedDatasourceDecorator {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
AppLogger.error('Erreur dans withCache pour $cacheKey', e);
|
AppLogger.error('Erreur dans withCache pour $cacheKey', error: e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,8 +136,15 @@ class UnexpectedFailure extends Failure {
|
|||||||
|
|
||||||
/// Fonctionnalité non implémentée
|
/// Fonctionnalité non implémentée
|
||||||
class NotImplementedFailure extends Failure {
|
class NotImplementedFailure extends Failure {
|
||||||
const NotImplementedFailure(super.message, [super.code]);
|
const NotImplementedFailure(
|
||||||
|
super.message, [
|
||||||
|
super.code,
|
||||||
|
super.isRetryable = false, // Not implemented features are not retryable
|
||||||
|
super.userFriendlyMessage =
|
||||||
|
'Cette fonctionnalité sera bientôt disponible. Nous travaillons dessus !',
|
||||||
|
]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'NotImplementedFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
String toString() =>
|
||||||
|
'NotImplementedFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../../core/di/injection_container.dart';
|
import '../../../../core/di/injection_container.dart';
|
||||||
import '../../../../shared/design_system/unionflow_design_system.dart';
|
import '../../../../shared/design_system/unionflow_design_system.dart';
|
||||||
|
import '../../../../shared/utils/snackbar_helper.dart';
|
||||||
import '../bloc/messaging_bloc.dart';
|
import '../bloc/messaging_bloc.dart';
|
||||||
import '../bloc/messaging_event.dart';
|
import '../bloc/messaging_event.dart';
|
||||||
import '../bloc/messaging_state.dart';
|
import '../bloc/messaging_state.dart';
|
||||||
@@ -117,12 +118,9 @@ class ConversationsPage extends StatelessWidget {
|
|||||||
return ConversationTile(
|
return ConversationTile(
|
||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Navigation vers la page de chat
|
SnackbarHelper.showNotImplemented(
|
||||||
// TODO: Implémenter navigation
|
context,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
'Affichage des messages',
|
||||||
SnackBar(
|
|
||||||
content: Text('Ouvrir conversation: ${conversation.name}'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -137,10 +135,7 @@ class ConversationsPage extends StatelessWidget {
|
|||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
backgroundColor: AppColors.primaryGreen,
|
backgroundColor: AppColors.primaryGreen,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// TODO: Ouvrir dialogue nouvelle conversation
|
SnackbarHelper.showNotImplemented(context, 'Nouvelle conversation');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Nouvelle conversation (à implémenter)')),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
child: const Icon(Icons.add, color: Colors.white),
|
child: const Icon(Icons.add, color: Colors.white),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../../../core/di/injection_container.dart';
|
import '../../../../core/di/injection_container.dart';
|
||||||
import '../../../../shared/design_system/unionflow_design_system.dart';
|
import '../../../../shared/design_system/unionflow_design_system.dart';
|
||||||
|
import '../../../../shared/utils/snackbar_helper.dart';
|
||||||
import '../../domain/entities/budget.dart';
|
import '../../domain/entities/budget.dart';
|
||||||
import '../bloc/budget_bloc.dart';
|
import '../bloc/budget_bloc.dart';
|
||||||
import '../bloc/budget_event.dart';
|
import '../bloc/budget_event.dart';
|
||||||
@@ -99,12 +100,7 @@ class _BudgetsListView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// TODO: Navigate to create budget page
|
SnackbarHelper.showNotImplemented(context, 'Création de budget');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Fonctionnalité en cours de développement'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('Nouveau budget'),
|
label: const Text('Nouveau budget'),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class ExportMembers {
|
|||||||
/// [format] - Format d'export ('csv' ou 'pdf')
|
/// [format] - Format d'export ('csv' ou 'pdf')
|
||||||
///
|
///
|
||||||
/// Retourne les données exportées (liste complète des membres selon critères)
|
/// Retourne les données exportées (liste complète des membres selon critères)
|
||||||
/// TODO: Ajouter endpoint backend GET /api/membres/export?format=csv|pdf
|
/// Note: Backend endpoint GET /api/membres/export?format=csv|excel|pdf disponible
|
||||||
/// Le use case actuel récupère toutes les données, l'export final se fait côté UI
|
/// Le use case actuel récupère toutes les données, l'export final se fait côté UI
|
||||||
Future<List<Map<String, dynamic>>> call({
|
Future<List<Map<String, dynamic>>> call({
|
||||||
MembreSearchCriteria? criteria,
|
MembreSearchCriteria? criteria,
|
||||||
|
|||||||
213
lib/shared/utils/snackbar_helper.dart
Normal file
213
lib/shared/utils/snackbar_helper.dart
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/// Centralized helper for showing consistent Snackbar messages
|
||||||
|
library snackbar_helper;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../core/error/failures.dart';
|
||||||
|
|
||||||
|
/// Helper class for showing consistent Snackbar messages throughout the app
|
||||||
|
class SnackbarHelper {
|
||||||
|
/// Show success message
|
||||||
|
static void showSuccess(
|
||||||
|
BuildContext context,
|
||||||
|
String message, {
|
||||||
|
Duration duration = const Duration(seconds: 3),
|
||||||
|
SnackBarAction? action,
|
||||||
|
}) {
|
||||||
|
final snackBar = SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.check_circle, color: Colors.white),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(message)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green[700],
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
duration: duration,
|
||||||
|
action: action,
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show error message
|
||||||
|
static void showError(
|
||||||
|
BuildContext context,
|
||||||
|
String message, {
|
||||||
|
Duration duration = const Duration(seconds: 4),
|
||||||
|
VoidCallback? onRetry,
|
||||||
|
}) {
|
||||||
|
final snackBar = SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, color: Colors.white),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(message)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red[700],
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
duration: duration,
|
||||||
|
action: onRetry != null
|
||||||
|
? SnackBarAction(
|
||||||
|
label: 'Réessayer',
|
||||||
|
textColor: Colors.white,
|
||||||
|
onPressed: onRetry,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show warning message
|
||||||
|
static void showWarning(
|
||||||
|
BuildContext context,
|
||||||
|
String message, {
|
||||||
|
Duration duration = const Duration(seconds: 4),
|
||||||
|
}) {
|
||||||
|
final snackBar = SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.warning_amber, color: Colors.white),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(message)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.orange[700],
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
duration: duration,
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show info message
|
||||||
|
static void showInfo(
|
||||||
|
BuildContext context,
|
||||||
|
String message, {
|
||||||
|
Duration duration = const Duration(seconds: 3),
|
||||||
|
}) {
|
||||||
|
final snackBar = SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.info_outline, color: Colors.white),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(message)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.blue[700],
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
duration: duration,
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show "not implemented" message with appropriate styling
|
||||||
|
static void showNotImplemented(
|
||||||
|
BuildContext context,
|
||||||
|
String? featureName,
|
||||||
|
) {
|
||||||
|
final message = featureName != null
|
||||||
|
? '$featureName sera bientôt disponible !'
|
||||||
|
: 'Cette fonctionnalité sera bientôt disponible !';
|
||||||
|
|
||||||
|
final snackBar = SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.construction, color: Colors.white),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(message)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.blue[700],
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
duration: const Duration(seconds: 4),
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show failure with appropriate styling based on failure type
|
||||||
|
static void showFailure(
|
||||||
|
BuildContext context,
|
||||||
|
Failure failure, {
|
||||||
|
VoidCallback? onRetry,
|
||||||
|
}) {
|
||||||
|
// Handle NotImplementedFailure specially
|
||||||
|
if (failure is NotImplementedFailure) {
|
||||||
|
showNotImplemented(context, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color backgroundColor;
|
||||||
|
IconData icon;
|
||||||
|
|
||||||
|
if (failure is NetworkFailure) {
|
||||||
|
backgroundColor = Colors.orange[700]!;
|
||||||
|
icon = Icons.wifi_off;
|
||||||
|
} else if (failure is UnauthorizedFailure) {
|
||||||
|
backgroundColor = Colors.red[700]!;
|
||||||
|
icon = Icons.lock_outline;
|
||||||
|
} else if (failure is ForbiddenFailure) {
|
||||||
|
backgroundColor = Colors.deepOrange[700]!;
|
||||||
|
icon = Icons.block;
|
||||||
|
} else if (failure is ValidationFailure) {
|
||||||
|
backgroundColor = Colors.amber[700]!;
|
||||||
|
icon = Icons.error_outline;
|
||||||
|
} else {
|
||||||
|
backgroundColor = Colors.red[700]!;
|
||||||
|
icon = Icons.error_outline;
|
||||||
|
}
|
||||||
|
|
||||||
|
final snackBar = SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.white),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(failure.getUserMessage())),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
duration: Duration(seconds: failure.isRetryable ? 6 : 4),
|
||||||
|
action: failure.isRetryable && onRetry != null
|
||||||
|
? SnackBarAction(
|
||||||
|
label: 'Réessayer',
|
||||||
|
textColor: Colors.white,
|
||||||
|
onPressed: onRetry,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show loading message (use with caution - prefer progress indicators)
|
||||||
|
static void showLoading(
|
||||||
|
BuildContext context,
|
||||||
|
String message, {
|
||||||
|
Duration duration = const Duration(seconds: 2),
|
||||||
|
}) {
|
||||||
|
final snackBar = SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(message)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.grey[800],
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
duration: duration,
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismiss current Snackbar
|
||||||
|
static void dismiss(BuildContext context) {
|
||||||
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,8 @@ class ErrorDisplayWidget extends StatelessWidget {
|
|||||||
return Icons.error_outline;
|
return Icons.error_outline;
|
||||||
} else if (failure is ServerFailure) {
|
} else if (failure is ServerFailure) {
|
||||||
return Icons.cloud_off;
|
return Icons.cloud_off;
|
||||||
|
} else if (failure is NotImplementedFailure) {
|
||||||
|
return Icons.construction;
|
||||||
} else {
|
} else {
|
||||||
return Icons.warning_amber;
|
return Icons.warning_amber;
|
||||||
}
|
}
|
||||||
@@ -103,6 +105,8 @@ class ErrorDisplayWidget extends StatelessWidget {
|
|||||||
return Colors.deepOrange;
|
return Colors.deepOrange;
|
||||||
} else if (failure is ValidationFailure) {
|
} else if (failure is ValidationFailure) {
|
||||||
return Colors.amber;
|
return Colors.amber;
|
||||||
|
} else if (failure is NotImplementedFailure) {
|
||||||
|
return Colors.blue[700]!;
|
||||||
} else {
|
} else {
|
||||||
return Theme.of(context).colorScheme.error;
|
return Theme.of(context).colorScheme.error;
|
||||||
}
|
}
|
||||||
@@ -122,6 +126,8 @@ class ErrorDisplayWidget extends StatelessWidget {
|
|||||||
return 'Données invalides';
|
return 'Données invalides';
|
||||||
} else if (failure is ServerFailure) {
|
} else if (failure is ServerFailure) {
|
||||||
return 'Erreur serveur';
|
return 'Erreur serveur';
|
||||||
|
} else if (failure is NotImplementedFailure) {
|
||||||
|
return 'Bientôt disponible';
|
||||||
} else {
|
} else {
|
||||||
return 'Une erreur est survenue';
|
return 'Une erreur est survenue';
|
||||||
}
|
}
|
||||||
@@ -213,6 +219,8 @@ class ErrorBanner extends StatelessWidget {
|
|||||||
return Icons.error_outline;
|
return Icons.error_outline;
|
||||||
} else if (failure is ServerFailure) {
|
} else if (failure is ServerFailure) {
|
||||||
return Icons.cloud_off;
|
return Icons.cloud_off;
|
||||||
|
} else if (failure is NotImplementedFailure) {
|
||||||
|
return Icons.construction;
|
||||||
} else {
|
} else {
|
||||||
return Icons.warning_amber;
|
return Icons.warning_amber;
|
||||||
}
|
}
|
||||||
@@ -227,6 +235,8 @@ class ErrorBanner extends StatelessWidget {
|
|||||||
return Colors.deepOrange;
|
return Colors.deepOrange;
|
||||||
} else if (failure is ValidationFailure) {
|
} else if (failure is ValidationFailure) {
|
||||||
return Colors.amber;
|
return Colors.amber;
|
||||||
|
} else if (failure is NotImplementedFailure) {
|
||||||
|
return Colors.blue[700]!;
|
||||||
} else {
|
} else {
|
||||||
return Theme.of(context).colorScheme.error;
|
return Theme.of(context).colorScheme.error;
|
||||||
}
|
}
|
||||||
@@ -245,6 +255,8 @@ class ErrorBanner extends StatelessWidget {
|
|||||||
return 'Données invalides';
|
return 'Données invalides';
|
||||||
} else if (failure is ServerFailure) {
|
} else if (failure is ServerFailure) {
|
||||||
return 'Erreur serveur';
|
return 'Erreur serveur';
|
||||||
|
} else if (failure is NotImplementedFailure) {
|
||||||
|
return 'Bientôt disponible';
|
||||||
} else {
|
} else {
|
||||||
return 'Erreur';
|
return 'Erreur';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user