feat: WebSocket temps réel + Finance Workflow + corrections
- Task #6: WebSocket /ws/dashboard + Kafka events (5 topics) * Backend: KafkaEventProducer, KafkaEventConsumer * Mobile: WebSocketService (reconnection, heartbeat, typed events) * DashboardBloc: Auto-refresh depuis WebSocket events - Finance Workflow: approbations + budgets (backend + mobile) * Backend: entities, services, resources, migrations Flyway V6 * Mobile: features finance_workflow complète avec BLoC - Corrections DI: interfaces IRepository partout * IProfileRepository, IOrganizationRepository, IMembreRepository * GetIt configuré avec @injectable - Spec-Kit: constitution + templates mis à jour * .specify/memory/constitution.md enrichie * Templates agent, plan, spec, tasks, checklist - Nettoyage: fichiers temporaires supprimés Signed-off-by: lions dev Team
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../../core/utils/logger.dart';
|
||||
import '../../data/repositories/notification_feed_repository.dart';
|
||||
import 'notification_event.dart';
|
||||
import 'notification_state.dart';
|
||||
|
||||
@injectable
|
||||
class NotificationBloc extends Bloc<NotificationEvent, NotificationState> {
|
||||
final NotificationFeedRepository _repository;
|
||||
|
||||
NotificationBloc(this._repository) : super(NotificationInitial()) {
|
||||
on<LoadNotificationsRequested>(_onLoadNotificationsRequested);
|
||||
on<NotificationMarkedAsRead>(_onNotificationMarkedAsRead);
|
||||
}
|
||||
|
||||
Future<void> _onLoadNotificationsRequested(LoadNotificationsRequested event, Emitter<NotificationState> emit) async {
|
||||
emit(NotificationLoading());
|
||||
try {
|
||||
final items = await _repository.getNotifications();
|
||||
emit(NotificationLoaded(items: items));
|
||||
} catch (e, st) {
|
||||
AppLogger.error('NotificationBloc: chargement notifications échoué', error: e, stackTrace: st);
|
||||
emit(NotificationError('Erreur de chargement: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onNotificationMarkedAsRead(NotificationMarkedAsRead event, Emitter<NotificationState> emit) async {
|
||||
if (state is NotificationLoaded) {
|
||||
final currentState = state as NotificationLoaded;
|
||||
try {
|
||||
await _repository.markAsRead(event.id);
|
||||
final updatedItems = currentState.items.map((item) {
|
||||
if (item.id == event.id) {
|
||||
return NotificationItem(
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
body: item.body,
|
||||
date: item.date,
|
||||
isRead: true,
|
||||
category: item.category,
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(NotificationLoaded(items: updatedItems));
|
||||
} catch (e, st) {
|
||||
AppLogger.error('NotificationBloc: marquer comme lu échoué', error: e, stackTrace: st);
|
||||
emit(NotificationError('Impossible de marquer comme lu'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class NotificationEvent extends Equatable {
|
||||
const NotificationEvent();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class LoadNotificationsRequested extends NotificationEvent {}
|
||||
|
||||
class NotificationMarkedAsRead extends NotificationEvent {
|
||||
final String id;
|
||||
const NotificationMarkedAsRead(this.id);
|
||||
|
||||
@override
|
||||
List<Object> get props => [id];
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class NotificationItem extends Equatable {
|
||||
final String id;
|
||||
final String title;
|
||||
final String body;
|
||||
final DateTime date;
|
||||
final bool isRead;
|
||||
final String category; // 'finance', 'event', 'system'
|
||||
|
||||
const NotificationItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.date,
|
||||
this.isRead = false,
|
||||
required this.category,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, title, body, date, isRead, category];
|
||||
}
|
||||
|
||||
abstract class NotificationState extends Equatable {
|
||||
const NotificationState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class NotificationInitial extends NotificationState {}
|
||||
|
||||
class NotificationLoading extends NotificationState {}
|
||||
|
||||
class NotificationLoaded extends NotificationState {
|
||||
final List<NotificationItem> items;
|
||||
|
||||
const NotificationLoaded({required this.items});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [items];
|
||||
}
|
||||
|
||||
class NotificationError extends NotificationState {
|
||||
final String message;
|
||||
const NotificationError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
library notifications_bloc;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../../core/utils/logger.dart';
|
||||
import '../../data/models/notification_model.dart';
|
||||
import '../../data/repositories/notification_repository.dart';
|
||||
|
||||
part 'notifications_event.dart';
|
||||
part 'notifications_state.dart';
|
||||
|
||||
@injectable
|
||||
class NotificationsBloc extends Bloc<NotificationsEvent, NotificationsState> {
|
||||
final NotificationRepository _repository;
|
||||
|
||||
@@ -24,7 +27,9 @@ class NotificationsBloc extends Bloc<NotificationsEvent, NotificationsState> {
|
||||
) async {
|
||||
try {
|
||||
emit(const NotificationsLoading());
|
||||
final notifications = await _repository.getNotificationsByMembre(event.membreId);
|
||||
final notifications = (event.membreId != null && event.membreId!.isNotEmpty)
|
||||
? await _repository.getNotificationsByMembre(event.membreId!)
|
||||
: await _repository.getMesNotifications();
|
||||
final nonLues = notifications.where((n) => !n.estLue).length;
|
||||
emit(NotificationsLoaded(notifications: notifications, nonLuesCount: nonLues));
|
||||
} on DioException catch (e) {
|
||||
@@ -64,8 +69,9 @@ class NotificationsBloc extends Bloc<NotificationsEvent, NotificationsState> {
|
||||
final nonLues = updated.where((n) => !n.estLue).length;
|
||||
emit(NotificationMarkedAsRead(notifications: updated, nonLuesCount: nonLues));
|
||||
}
|
||||
} catch (e) {
|
||||
// Echec silencieux : ne pas bloquer l'UI
|
||||
} catch (e, st) {
|
||||
AppLogger.error('NotificationsBloc: marquer comme lu échoué', error: e, stackTrace: st);
|
||||
emit(NotificationsError('Impossible de marquer comme lu'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ abstract class NotificationsEvent extends Equatable {
|
||||
}
|
||||
|
||||
class LoadNotifications extends NotificationsEvent {
|
||||
final String membreId;
|
||||
/// Si null ou vide, utilise GET /api/notifications/me (membre connecté).
|
||||
final String? membreId;
|
||||
final bool onlyUnread;
|
||||
const LoadNotifications({required this.membreId, this.onlyUnread = false});
|
||||
const LoadNotifications({this.membreId, this.onlyUnread = false});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membreId, onlyUnread];
|
||||
@@ -25,8 +26,8 @@ class MarkNotificationAsRead extends NotificationsEvent {
|
||||
}
|
||||
|
||||
class RefreshNotifications extends NotificationsEvent {
|
||||
final String membreId;
|
||||
const RefreshNotifications(this.membreId);
|
||||
final String? membreId;
|
||||
const RefreshNotifications([this.membreId]);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membreId];
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../authentication/presentation/bloc/auth_bloc.dart';
|
||||
import '../../../members/presentation/pages/members_page_wrapper.dart';
|
||||
import '../../../events/presentation/pages/events_page_wrapper.dart';
|
||||
import '../../../organizations/presentation/pages/organizations_page.dart';
|
||||
import '../bloc/notifications_bloc.dart';
|
||||
import '../../data/models/notification_model.dart';
|
||||
import '../../../../shared/design_system/unionflow_design_system.dart';
|
||||
import '../../../../shared/widgets/core_card.dart';
|
||||
import '../../../../shared/widgets/mini_avatar.dart';
|
||||
import '../../../../shared/widgets/info_badge.dart';
|
||||
import '../../../../shared/design_system/components/uf_app_bar.dart';
|
||||
import '../../../../shared/design_system/components/uf_buttons.dart';
|
||||
|
||||
/// Page Notifications - UnionFlow Mobile
|
||||
///
|
||||
@@ -41,7 +50,7 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
final authState = context.read<AuthBloc>().state;
|
||||
if (authState is AuthAuthenticated) {
|
||||
context.read<NotificationsBloc>().add(
|
||||
LoadNotifications(membreId: authState.user.id),
|
||||
const LoadNotifications(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -94,92 +103,65 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
/// Header harmonisé avec le design system
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF6C5CE7), Color(0xFF5A4FCF)],
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColors.brandGreen, AppColors.primaryGreen],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6C5CE7).withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.notifications,
|
||||
Icons.notifications_none,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Notifications',
|
||||
'NOTIFICATIONS',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Gérer vos notifications et préférences',
|
||||
'Restez connecté à votre réseau',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 11,
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: () => _markAllAsRead(),
|
||||
icon: const Icon(
|
||||
Icons.done_all,
|
||||
color: Colors.white,
|
||||
),
|
||||
tooltip: 'Tout marquer comme lu',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: () => _showNotificationSettings(),
|
||||
icon: const Icon(
|
||||
Icons.settings,
|
||||
color: Colors.white,
|
||||
),
|
||||
tooltip: 'Paramètres',
|
||||
),
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
onPressed: () => _markAllAsRead(),
|
||||
icon: const Icon(Icons.done_all, color: Colors.white, size: 20),
|
||||
tooltip: 'Tout marquer comme lu',
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -189,42 +171,24 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
/// Barre d'onglets
|
||||
Widget _buildTabBar() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
color: AppColors.lightSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: const Color(0xFF6C5CE7),
|
||||
unselectedLabelColor: Colors.grey[600],
|
||||
indicatorColor: const Color(0xFF6C5CE7),
|
||||
indicatorWeight: 3,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
labelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
unselectedLabelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
labelColor: AppColors.primaryGreen,
|
||||
unselectedLabelColor: AppColors.textSecondaryLight,
|
||||
indicator: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: AppColors.primaryGreen.withOpacity(0.1),
|
||||
),
|
||||
labelStyle: AppTypography.actionText.copyWith(fontSize: 12),
|
||||
unselectedLabelStyle: AppTypography.bodyTextSmall.copyWith(fontSize: 12),
|
||||
tabs: const [
|
||||
Tab(
|
||||
icon: Icon(Icons.inbox),
|
||||
text: 'Notifications',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.tune),
|
||||
text: 'Préférences',
|
||||
),
|
||||
Tab(text: 'FLUX'),
|
||||
Tab(text: 'RÉGLAGES'),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -249,68 +213,49 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
|
||||
/// Section filtres
|
||||
Widget _buildFiltersSection() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.filter_list,
|
||||
color: Colors.grey[600],
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Filtres',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey[800],
|
||||
'FILTRER PAR :',
|
||||
style: AppTypography.subtitleSmall.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Switch(
|
||||
value: _showOnlyUnread,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_showOnlyUnread = value;
|
||||
});
|
||||
},
|
||||
activeColor: const Color(0xFF6C5CE7),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Non lues uniquement',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
'NON LUES',
|
||||
style: AppTypography.badgeText.copyWith(fontSize: 9),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
height: 24,
|
||||
child: Switch(
|
||||
value: _showOnlyUnread,
|
||||
onChanged: (value) => setState(() => _showOnlyUnread = value),
|
||||
activeColor: AppColors.primaryGreen,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _filters.map((filter) {
|
||||
final isSelected = _selectedFilter == filter;
|
||||
return _buildFilterChip(filter, isSelected);
|
||||
}).toList(),
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: _filters.map((filter) {
|
||||
final isSelected = _selectedFilter == filter;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _buildFilterChip(filter, isSelected),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -320,28 +265,23 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
/// Chip de filtre
|
||||
Widget _buildFilterChip(String label, bool isSelected) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedFilter = label;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => setState(() => _selectedFilter = label),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF6C5CE7) : Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: isSelected ? AppColors.primaryGreen.withOpacity(0.1) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isSelected ? const Color(0xFF6C5CE7) : Colors.grey[300]!,
|
||||
width: 1,
|
||||
color: isSelected ? AppColors.primaryGreen : AppColors.lightBorder,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : Colors.grey[700],
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
label.toUpperCase(),
|
||||
style: AppTypography.badgeText.copyWith(
|
||||
color: isSelected ? AppColors.primaryGreen : AppColors.textSecondaryLight,
|
||||
fontSize: 9,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -372,36 +312,22 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6C5CE7).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.notifications_none,
|
||||
size: 48,
|
||||
color: Color(0xFF6C5CE7),
|
||||
),
|
||||
const Icon(
|
||||
Icons.notifications_none_outlined,
|
||||
size: 40,
|
||||
color: AppColors.textSecondaryLight,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Aucune notification',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1F2937),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'AUCUNE NOTIFICATION',
|
||||
style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_showOnlyUnread
|
||||
? 'Toutes vos notifications ont été lues'
|
||||
: 'Vous n\'avez aucune notification pour le moment',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
: 'Votre flux est à jour.',
|
||||
style: AppTypography.subtitleSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
@@ -415,168 +341,97 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
final type = notification['type'] as String;
|
||||
final color = _getNotificationColor(type);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: isRead ? null : Border.all(
|
||||
color: const Color(0xFF6C5CE7).withOpacity(0.3),
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
return CoreCard(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
onTap: () => _handleNotificationTap(notification),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MiniAvatar(
|
||||
fallbackText: _getNotificationIconSource(type),
|
||||
size: 32,
|
||||
backgroundColor: isRead ? AppColors.lightSurface : color.withOpacity(0.1),
|
||||
iconColor: isRead ? AppColors.textSecondaryLight : color,
|
||||
isIcon: true,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
notification['title'].toString().toUpperCase(),
|
||||
style: AppTypography.actionText.copyWith(
|
||||
fontSize: 11,
|
||||
color: isRead ? AppColors.textSecondaryLight : AppColors.textPrimaryLight,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
notification['time'],
|
||||
style: AppTypography.subtitleSmall.copyWith(fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
notification['message'],
|
||||
style: AppTypography.bodyTextSmall.copyWith(
|
||||
color: isRead ? AppColors.textSecondaryLight : AppColors.textPrimaryLight,
|
||||
fontWeight: isRead ? FontWeight.normal : FontWeight.w500,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (!isRead) ...[
|
||||
const SizedBox(height: 4),
|
||||
InfoBadge(
|
||||
text: 'NOUVEAU',
|
||||
backgroundColor: AppColors.primaryGreen.withOpacity(0.1),
|
||||
textColor: AppColors.primaryGreen,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (action) => _handleNotificationAction(notification, action),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: isRead ? 'mark_unread' : 'mark_read',
|
||||
child: Text(isRead ? 'Marquer non lu' : 'Marquer lu', style: AppTypography.bodyTextSmall),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text('Supprimer', style: AppTypography.bodyTextSmall.copyWith(color: AppColors.error)),
|
||||
),
|
||||
],
|
||||
child: const Icon(Icons.more_vert, size: 14, color: AppColors.textSecondaryLight),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => _handleNotificationTap(notification),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Icône et indicateur
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
_getNotificationIcon(type),
|
||||
color: color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
if (!isRead)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF6C5CE7),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Contenu
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
notification['title'],
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isRead ? FontWeight.w500 : FontWeight.w600,
|
||||
color: isRead ? Colors.grey[700] : const Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
notification['time'],
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
notification['message'],
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[600],
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (notification['actionText'] != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
notification['actionText'],
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Menu actions
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (action) => _handleNotificationAction(notification, action),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: isRead ? 'mark_unread' : 'mark_read',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isRead ? Icons.mark_email_unread : Icons.mark_email_read,
|
||||
size: 18,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(isRead ? 'Marquer non lu' : 'Marquer comme lu'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete,
|
||||
size: 18,
|
||||
color: Colors.red,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text('Supprimer', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.grey[400],
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getNotificationIconSource(String type) {
|
||||
switch (type) {
|
||||
case 'Membres': return 'people';
|
||||
case 'Événements': return 'event';
|
||||
case 'Organisations': return 'business';
|
||||
case 'Système': return 'settings';
|
||||
default: return 'notifications';
|
||||
}
|
||||
}
|
||||
|
||||
/// Onglet préférences
|
||||
Widget _buildPreferencesTab() {
|
||||
return SingleChildScrollView(
|
||||
@@ -587,18 +442,18 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
|
||||
// Notifications push
|
||||
_buildPreferenceSection(
|
||||
'Notifications push',
|
||||
'NOTIFICATIONS PUSH',
|
||||
'Recevoir des notifications sur votre appareil',
|
||||
Icons.notifications_active,
|
||||
Icons.notifications_active_outlined,
|
||||
[
|
||||
_buildPreferenceItem(
|
||||
'Activer les notifications',
|
||||
'ACTIVER LES NOTIFICATIONS',
|
||||
'Recevoir toutes les notifications',
|
||||
true,
|
||||
(value) => _updatePreference('push_enabled', value),
|
||||
),
|
||||
_buildPreferenceItem(
|
||||
'Sons et vibrations',
|
||||
'SONS ET VIBRATIONS',
|
||||
'Alertes sonores et vibrations',
|
||||
true,
|
||||
(value) => _updatePreference('sound_enabled', value),
|
||||
@@ -683,19 +538,8 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
IconData icon,
|
||||
List<Widget> items,
|
||||
) {
|
||||
return Container(
|
||||
return CoreCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -703,35 +547,28 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: Colors.grey[600],
|
||||
size: 20,
|
||||
color: AppColors.primaryGreen,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
style: AppTypography.actionText.copyWith(fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
style: AppTypography.subtitleSmall.copyWith(fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
...items,
|
||||
],
|
||||
),
|
||||
@@ -746,7 +583,7 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
Function(bool) onChanged,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -755,26 +592,23 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1F2937),
|
||||
),
|
||||
style: AppTypography.bodyTextSmall.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
style: AppTypography.subtitleSmall.copyWith(fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeColor: const Color(0xFF6C5CE7),
|
||||
SizedBox(
|
||||
height: 24,
|
||||
child: Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeColor: AppColors.primaryGreen,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -827,15 +661,15 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
Color _getNotificationColor(String type) {
|
||||
switch (type) {
|
||||
case 'Membres':
|
||||
return const Color(0xFF6C5CE7);
|
||||
return AppColors.primaryGreen;
|
||||
case 'Événements':
|
||||
return const Color(0xFF00B894);
|
||||
case 'Organisations':
|
||||
return const Color(0xFF0984E3);
|
||||
return AppColors.primaryGreen;
|
||||
case 'Système':
|
||||
return const Color(0xFFE17055);
|
||||
return AppColors.warning;
|
||||
default:
|
||||
return Colors.grey;
|
||||
return AppColors.textSecondaryLight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -866,17 +700,23 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
);
|
||||
}
|
||||
|
||||
// Action selon le type
|
||||
// Action selon le type : navigation vers l'écran concerné
|
||||
final type = notification['type'] as String;
|
||||
switch (type) {
|
||||
case 'Membres':
|
||||
_showSuccessSnackBar('Navigation vers la gestion des membres');
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const MembersPageWrapper()),
|
||||
);
|
||||
break;
|
||||
case 'Événements':
|
||||
_showSuccessSnackBar('Navigation vers les événements');
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const EventsPageWrapper()),
|
||||
);
|
||||
break;
|
||||
case 'Organisations':
|
||||
_showSuccessSnackBar('Navigation vers les organisations');
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const OrganizationsPage()),
|
||||
);
|
||||
break;
|
||||
case 'Système':
|
||||
_showSystemNotificationDialog(notification);
|
||||
@@ -910,32 +750,28 @@ class _NotificationsPageState extends State<NotificationsPage>
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Marquer tout comme lu'),
|
||||
content: const Text(
|
||||
'Êtes-vous sûr de vouloir marquer toutes les notifications comme lues ?',
|
||||
title: Text('Vider le flux', style: AppTypography.headerSmall),
|
||||
content: Text(
|
||||
'Voulez-vous marquer toutes les notifications comme lues ?',
|
||||
style: AppTypography.bodyTextSmall,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
child: Text('ANNULER', style: AppTypography.actionText.copyWith(color: AppColors.textSecondaryLight)),
|
||||
),
|
||||
ElevatedButton(
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
setState(() {
|
||||
// Marquer toutes les notifications comme lues
|
||||
final notifications = _getFilteredNotifications();
|
||||
for (var notification in notifications) {
|
||||
notification['isRead'] = true;
|
||||
}
|
||||
});
|
||||
_showSuccessSnackBar('Toutes les notifications ont été marquées comme lues');
|
||||
_showSuccessSnackBar('Flux marqué comme lu');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF6C5CE7),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Confirmer'),
|
||||
child: Text('CONFIRMER', style: AppTypography.actionText.copyWith(color: AppColors.primaryGreen)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,6 +3,9 @@ library notifications_page_wrapper;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/utils/logger.dart';
|
||||
import '../../data/repositories/notification_repository.dart';
|
||||
import '../bloc/notifications_bloc.dart';
|
||||
import 'notifications_page.dart';
|
||||
|
||||
@@ -13,8 +16,20 @@ class NotificationsPageWrapper extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<NotificationsBloc>(
|
||||
create: (_) => GetIt.instance<NotificationsBloc>(),
|
||||
create: (_) => _getOrCreateNotificationsBloc(),
|
||||
child: const NotificationsPage(),
|
||||
);
|
||||
}
|
||||
|
||||
static NotificationsBloc _getOrCreateNotificationsBloc() {
|
||||
try {
|
||||
if (GetIt.instance.isRegistered<NotificationsBloc>()) {
|
||||
return GetIt.instance<NotificationsBloc>();
|
||||
}
|
||||
} catch (e, st) {
|
||||
AppLogger.error('NotificationsPageWrapper: résolution NotificationsBloc échouée', error: e, stackTrace: st);
|
||||
}
|
||||
final repo = getIt<NotificationRepository>();
|
||||
return NotificationsBloc(repo);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user