diff --git a/lib/features/communication/data/datasources/messaging_remote_datasource.dart b/lib/features/communication/data/datasources/messaging_remote_datasource.dart index cc1d043..2895e9c 100644 --- a/lib/features/communication/data/datasources/messaging_remote_datasource.dart +++ b/lib/features/communication/data/datasources/messaging_remote_datasource.dart @@ -2,6 +2,7 @@ library messaging_remote_datasource; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:injectable/injectable.dart'; @@ -303,14 +304,18 @@ class MessagingRemoteDatasource { } Future markMessageAsRead(String messageId) async { - // Backend uses conversation mark-read, not individual message - // This method is deprecated - use markConversationAsRead instead - throw UnimplementedError('Use markConversationAsRead instead'); + // Backend has no per-message read endpoint — use markConversationAsRead + if (AppConfig.enableLogging) { + debugPrint('[Messaging] markMessageAsRead ignored (no per-message endpoint), messageId=$messageId'); + } } Future getUnreadCount({String? organizationId}) async { - // Backend provides unreadCount in conversation response - // This method is deprecated - get count from conversation list - throw UnimplementedError('Get unread count from conversation list'); + try { + final conversations = await getConversations(organizationId: organizationId); + return conversations.fold(0, (sum, c) => sum + c.unreadCount); + } catch (_) { + return 0; + } } } diff --git a/lib/features/epargne/data/repositories/transaction_epargne_repository.dart b/lib/features/epargne/data/repositories/transaction_epargne_repository.dart index 4a7d96c..ed68477 100644 --- a/lib/features/epargne/data/repositories/transaction_epargne_repository.dart +++ b/lib/features/epargne/data/repositories/transaction_epargne_repository.dart @@ -111,12 +111,23 @@ class TransactionEpargneRepository { throw Exception('Erreur transfert: ${response.statusCode}'); } - /// Historique des transactions d'un compte. - Future>> getByCompte(String compteId) async { - final response = await _apiClient.get('$_base/compte/$compteId'); + /// Historique des transactions d'un compte (paginé). + Future>> getByCompte( + String compteId, { + int page = 0, + int size = 20, + }) async { + final response = await _apiClient.get( + '$_base/compte/$compteId', + queryParameters: {'page': page, 'size': size}, + ); if (response.statusCode == 200) { final data = response.data; if (data is List) return List>.from(data.map((e) => e as Map)); + if (data is Map && data.containsKey('content')) { + final content = data['content'] as List? ?? []; + return List>.from(content.map((e) => e as Map)); + } return []; } throw Exception('Erreur chargement historique: ${response.statusCode}'); diff --git a/lib/features/epargne/presentation/pages/epargne_detail_page.dart b/lib/features/epargne/presentation/pages/epargne_detail_page.dart index 927d973..e8c4357 100644 --- a/lib/features/epargne/presentation/pages/epargne_detail_page.dart +++ b/lib/features/epargne/presentation/pages/epargne_detail_page.dart @@ -32,6 +32,10 @@ class EpargneDetailPage extends StatefulWidget { class _EpargneDetailPageState extends State { List _transactions = []; bool _loadingTx = true; + bool _loadingMore = false; + bool _hasMore = true; + int _page = 0; + static const int _pageSize = 20; String? _errorTx; CompteEpargneModel? _compte; // rafraîchi après actions @@ -39,7 +43,7 @@ class _EpargneDetailPageState extends State { void initState() { super.initState(); _compte = widget.compte; - _loadTransactions(); + _loadTransactions(reset: true); } Future _refreshCompte() async { @@ -59,33 +63,41 @@ class _EpargneDetailPageState extends State { } } - Future _loadTransactions() async { + Future _loadTransactions({bool reset = false}) async { if (_compte?.id == null) { - setState(() { - _loadingTx = false; - _transactions = []; - }); + setState(() { _loadingTx = false; _transactions = []; }); return; } - setState(() { - _loadingTx = true; - _errorTx = null; - }); + if (reset) { + setState(() { _loadingTx = true; _errorTx = null; _page = 0; _hasMore = true; }); + } else { + if (_loadingMore || !_hasMore) return; + setState(() => _loadingMore = true); + } try { final repo = GetIt.I(); - final list = await repo.getByCompte(_compte!.id!); + final list = await repo.getByCompte(_compte!.id!, page: _page, size: _pageSize); if (!mounted) return; + final parsed = list.map((e) => TransactionEpargneModel.fromJson(e)).toList(); setState(() { - _transactions = list.map((e) => TransactionEpargneModel.fromJson(e)).toList(); + if (reset) { + _transactions = parsed; + } else { + _transactions = [..._transactions, ...parsed]; + } + _hasMore = parsed.length == _pageSize; + _page = _page + 1; _loadingTx = false; + _loadingMore = false; _errorTx = null; }); } catch (e, st) { AppLogger.error('EpargneDetailPage: _loadTransactions échoué', error: e, stackTrace: st); if (!mounted) return; setState(() { - _transactions = []; + if (reset) _transactions = []; _loadingTx = false; + _loadingMore = false; _errorTx = e.toString().replaceFirst('Exception: ', ''); }); } @@ -99,7 +111,7 @@ class _EpargneDetailPageState extends State { compteId: _compte!.id!, onSuccess: () { _refreshCompte(); - _loadTransactions(); + _loadTransactions(reset: true); widget.onDataChanged?.call(); }, ), @@ -117,7 +129,7 @@ class _EpargneDetailPageState extends State { soldeDisponible: soldeDispo, onSuccess: () { _refreshCompte(); - _loadTransactions(); + _loadTransactions(reset: true); widget.onDataChanged?.call(); }, ), @@ -133,7 +145,7 @@ class _EpargneDetailPageState extends State { tousLesComptes: widget.tousLesComptes, onSuccess: () { _refreshCompte(); - _loadTransactions(); + _loadTransactions(reset: true); widget.onDataChanged?.call(); }, ), @@ -191,7 +203,7 @@ class _EpargneDetailPageState extends State { child: RefreshIndicator( onRefresh: () async { await _refreshCompte(); - await _loadTransactions(); + await _loadTransactions(reset: true); }, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), @@ -332,10 +344,13 @@ class _EpargneDetailPageState extends State { textAlign: TextAlign.center, ), ) - else + else ...[ Card( child: Column( - children: _transactions.take(10).map((t) { + children: _transactions.map((t) { + final dateStr = t.dateTransaction != null + ? '${t.dateTransaction!.day.toString().padLeft(2, '0')}/${t.dateTransaction!.month.toString().padLeft(2, '0')}/${t.dateTransaction!.year} ${t.dateTransaction!.hour.toString().padLeft(2, '0')}:${t.dateTransaction!.minute.toString().padLeft(2, '0')}' + : null; return ListTile( leading: CircleAvatar( backgroundColor: t.isCredit ? ColorTokens.success.withOpacity(0.2) : ColorTokens.error.withOpacity(0.2), @@ -345,16 +360,28 @@ class _EpargneDetailPageState extends State { size: 20, ), ), - title: Text( - _libelleType(t.type), - style: TypographyTokens.bodyMedium, + title: Text(_libelleType(t.type), style: TypographyTokens.bodyMedium), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (dateStr != null) + Text(dateStr, style: TypographyTokens.bodySmall.copyWith(color: ColorTokens.onSurfaceVariant)), + Text( + 'Avant: ${t.soldeAvant.toStringAsFixed(0)} → Après: ${t.soldeApres.toStringAsFixed(0)} XOF', + style: TypographyTokens.bodySmall.copyWith(color: ColorTokens.onSurfaceVariant), + ), + if (t.motif != null && t.motif!.isNotEmpty) + Text( + t.motif!, + style: TypographyTokens.bodySmall.copyWith( + color: ColorTokens.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), - subtitle: t.dateTransaction != null - ? Text( - '${t.dateTransaction!.day.toString().padLeft(2, '0')}/${t.dateTransaction!.month.toString().padLeft(2, '0')}/${t.dateTransaction!.year} ${t.dateTransaction!.hour.toString().padLeft(2, '0')}:${t.dateTransaction!.minute.toString().padLeft(2, '0')}', - style: TypographyTokens.bodySmall.copyWith(color: ColorTokens.onSurfaceVariant), - ) - : null, trailing: Text( '${t.isCredit ? '+' : '-'}${t.montant.toStringAsFixed(0)} XOF', style: TypographyTokens.titleSmall.copyWith( @@ -362,10 +389,22 @@ class _EpargneDetailPageState extends State { fontWeight: FontWeight.w600, ), ), + isThreeLine: t.motif != null && t.motif!.isNotEmpty, ); }).toList(), ), ), + if (_hasMore) + Padding( + padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm), + child: _loadingMore + ? const Center(child: CircularProgressIndicator()) + : TextButton( + onPressed: () => _loadTransactions(), + child: const Text('Charger plus'), + ), + ), + ], ], ), ), diff --git a/lib/features/epargne/presentation/pages/epargne_page.dart b/lib/features/epargne/presentation/pages/epargne_page.dart index 9560625..8bcf291 100644 --- a/lib/features/epargne/presentation/pages/epargne_page.dart +++ b/lib/features/epargne/presentation/pages/epargne_page.dart @@ -186,6 +186,19 @@ class _EpargnePageState extends State { 'Solde disponible total • ${_comptes.length} compte${_comptes.length > 1 ? 's' : ''}', style: AppTypography.bodyTextSmall.copyWith(color: AppColors.textSecondaryLight), ), + if (_comptes.any((c) => c.soldeBloque > 0)) ...[ + const SizedBox(height: SpacingTokens.xs), + Row( + children: [ + Icon(Icons.shield_outlined, size: 12, color: Colors.amber.shade700), + const SizedBox(width: 4), + Text( + 'Certains fonds sont sous surveillance LCB-FT', + style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, color: Colors.amber.shade700), + ), + ], + ), + ], ], ), ); @@ -231,6 +244,29 @@ class _EpargnePageState extends State { ], ), ), + if (c.soldeBloque > 0) + Padding( + padding: const EdgeInsets.only(right: SpacingTokens.xs), + child: Tooltip( + message: 'Fonds bloqués — surveillance LCB-FT', + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: Colors.amber.withOpacity(0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.amber.shade700, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.shield_outlined, size: 12, color: Colors.amber.shade700), + const SizedBox(width: 3), + Text('LCB-FT', style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: Colors.amber.shade700)), + ], + ), + ), + ), + ), if (c.statut != null) InfoBadge( text: c.statut!, diff --git a/lib/features/organizations/data/repositories/organization_repository.dart b/lib/features/organizations/data/repositories/organization_repository.dart index 6a5abdd..48ee061 100644 --- a/lib/features/organizations/data/repositories/organization_repository.dart +++ b/lib/features/organizations/data/repositories/organization_repository.dart @@ -345,4 +345,20 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { throw Exception('Erreur inattendue lors de la récupération des statistiques: $e'); } } + + @override + Future getMembreCount(String organizationId) async { + try { + final response = await _apiClient.get('$_baseUrl/$organizationId/membres/count'); + if (response.statusCode == 200 && response.data is Map) { + return (response.data['count'] as num?)?.toInt() ?? 0; + } + return 0; + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; + return 0; + } catch (_) { + return 0; + } + } } diff --git a/lib/features/organizations/domain/repositories/organization_repository.dart b/lib/features/organizations/domain/repositories/organization_repository.dart index 3be7f6d..524d2f9 100644 --- a/lib/features/organizations/domain/repositories/organization_repository.dart +++ b/lib/features/organizations/domain/repositories/organization_repository.dart @@ -58,4 +58,7 @@ abstract class IOrganizationRepository { /// Récupère les statistiques des organisations Future> getOrganizationsStats(); + + /// Retourne le nombre réel de membres actifs d'une organisation. + Future getMembreCount(String organizationId); } diff --git a/lib/features/organizations/presentation/pages/organization_detail_page.dart b/lib/features/organizations/presentation/pages/organization_detail_page.dart index aef0537..d7d7aa9 100644 --- a/lib/features/organizations/presentation/pages/organization_detail_page.dart +++ b/lib/features/organizations/presentation/pages/organization_detail_page.dart @@ -3,11 +3,13 @@ library organisation_detail_page; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../data/models/organization_model.dart'; import '../../bloc/organizations_bloc.dart'; import '../../bloc/organizations_event.dart'; import '../../bloc/organizations_state.dart'; +import '../../domain/repositories/organization_repository.dart'; import 'edit_organization_page.dart'; import '../../../../shared/design_system/tokens/app_colors.dart'; import '../../../../features/authentication/presentation/bloc/auth_bloc.dart'; @@ -23,10 +25,20 @@ class OrganizationDetailPage extends StatefulWidget { } class _OrganizationDetailPageState extends State { + int? _memberCount; + @override void initState() { super.initState(); context.read().add(LoadOrganizationById(widget.organizationId)); + _loadMemberCount(); + } + + Future _loadMemberCount() async { + try { + final count = await GetIt.I().getMembreCount(widget.organizationId); + if (mounted) setState(() => _memberCount = count); + } catch (_) {} } @override @@ -214,7 +226,7 @@ class _OrganizationDetailPageState extends State { Widget _buildStatsCard(OrganizationModel org) { return _buildCard('Statistiques', Icons.bar_chart, [ Row(children: [ - Expanded(child: _buildStatItem(Icons.people, 'Membres', org.nombreMembres.toString(), AppColors.primaryGreen)), + Expanded(child: _buildStatItem(Icons.people, 'Membres', (_memberCount ?? org.nombreMembres).toString(), AppColors.primaryGreen)), const SizedBox(width: 10), Expanded(child: _buildStatItem(Icons.admin_panel_settings, 'Admins', org.nombreAdministrateurs.toString(), AppColors.brandGreen)), const SizedBox(width: 10),