feat(mobile): consolidation modules epargne, messaging, organisations

- Epargne: badge LCB-FT (bouclier ambre) sur comptes avec fonds bloques + note recap
- EpargneDetail: historique pagine (page/size), affichage soldeAvant/soldeApres/motif dans chaque transaction, bouton "Charger plus"
- TransactionEpargneRepository: getByCompte accepte page et size, gere reponse paginee Spring (content[])
- MessagingDatasource: markMessageAsRead silencieuse (pas d'endpoint unitaire), getUnreadCount somme unreadCount des conversations
- OrganizationDetail: _memberCount charge le vrai nombre depuis GET /membres/count, affiche la valeur reelle au lieu de nombreMembres (toujours 0)
This commit is contained in:
dahoud
2026-04-05 13:37:21 +00:00
parent 65b5c79c43
commit 289efc4956
7 changed files with 160 additions and 38 deletions

View File

@@ -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<void> 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<int> 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<int>(0, (sum, c) => sum + c.unreadCount);
} catch (_) {
return 0;
}
}
}

View File

@@ -111,12 +111,23 @@ class TransactionEpargneRepository {
throw Exception('Erreur transfert: ${response.statusCode}');
}
/// Historique des transactions d'un compte.
Future<List<Map<String, dynamic>>> getByCompte(String compteId) async {
final response = await _apiClient.get('$_base/compte/$compteId');
/// Historique des transactions d'un compte (paginé).
Future<List<Map<String, dynamic>>> 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<Map<String, dynamic>>.from(data.map((e) => e as Map<String, dynamic>));
if (data is Map && data.containsKey('content')) {
final content = data['content'] as List? ?? [];
return List<Map<String, dynamic>>.from(content.map((e) => e as Map<String, dynamic>));
}
return [];
}
throw Exception('Erreur chargement historique: ${response.statusCode}');

View File

@@ -32,6 +32,10 @@ class EpargneDetailPage extends StatefulWidget {
class _EpargneDetailPageState extends State<EpargneDetailPage> {
List<TransactionEpargneModel> _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<EpargneDetailPage> {
void initState() {
super.initState();
_compte = widget.compte;
_loadTransactions();
_loadTransactions(reset: true);
}
Future<void> _refreshCompte() async {
@@ -59,33 +63,41 @@ class _EpargneDetailPageState extends State<EpargneDetailPage> {
}
}
Future<void> _loadTransactions() async {
Future<void> _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<TransactionEpargneRepository>();
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<EpargneDetailPage> {
compteId: _compte!.id!,
onSuccess: () {
_refreshCompte();
_loadTransactions();
_loadTransactions(reset: true);
widget.onDataChanged?.call();
},
),
@@ -117,7 +129,7 @@ class _EpargneDetailPageState extends State<EpargneDetailPage> {
soldeDisponible: soldeDispo,
onSuccess: () {
_refreshCompte();
_loadTransactions();
_loadTransactions(reset: true);
widget.onDataChanged?.call();
},
),
@@ -133,7 +145,7 @@ class _EpargneDetailPageState extends State<EpargneDetailPage> {
tousLesComptes: widget.tousLesComptes,
onSuccess: () {
_refreshCompte();
_loadTransactions();
_loadTransactions(reset: true);
widget.onDataChanged?.call();
},
),
@@ -191,7 +203,7 @@ class _EpargneDetailPageState extends State<EpargneDetailPage> {
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<EpargneDetailPage> {
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<EpargneDetailPage> {
size: 20,
),
),
title: Text(
_libelleType(t.type),
style: TypographyTokens.bodyMedium,
),
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')}',
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),
)
: null,
),
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,
),
],
),
trailing: Text(
'${t.isCredit ? '+' : '-'}${t.montant.toStringAsFixed(0)} XOF',
style: TypographyTokens.titleSmall.copyWith(
@@ -362,10 +389,22 @@ class _EpargneDetailPageState extends State<EpargneDetailPage> {
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'),
),
),
],
],
),
),

View File

@@ -186,6 +186,19 @@ class _EpargnePageState extends State<EpargnePage> {
'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<EpargnePage> {
],
),
),
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!,

View File

@@ -345,4 +345,20 @@ class OrganizationRepositoryImpl implements IOrganizationRepository {
throw Exception('Erreur inattendue lors de la récupération des statistiques: $e');
}
}
@override
Future<int> 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;
}
}
}

View File

@@ -58,4 +58,7 @@ abstract class IOrganizationRepository {
/// Récupère les statistiques des organisations
Future<Map<String, dynamic>> getOrganizationsStats();
/// Retourne le nombre réel de membres actifs d'une organisation.
Future<int> getMembreCount(String organizationId);
}

View File

@@ -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<OrganizationDetailPage> {
int? _memberCount;
@override
void initState() {
super.initState();
context.read<OrganizationsBloc>().add(LoadOrganizationById(widget.organizationId));
_loadMemberCount();
}
Future<void> _loadMemberCount() async {
try {
final count = await GetIt.I<IOrganizationRepository>().getMembreCount(widget.organizationId);
if (mounted) setState(() => _memberCount = count);
} catch (_) {}
}
@override
@@ -214,7 +226,7 @@ class _OrganizationDetailPageState extends State<OrganizationDetailPage> {
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),