Compare commits

..

9 Commits

Author SHA1 Message Date
dahoud
970e7063c5 chore: lib/main.dart — entry point avec environnement configuré 2026-04-15 20:27:50 +00:00
dahoud
22dece52ef test: couverture tests modules (communication, contributions, dashboard, events, members, organizations, profile, reports, settings, finance_workflow, core/network) 2026-04-15 20:27:38 +00:00
dahoud
7cd7c6fc9e feat(shared): legacy presentation/ + shared design system + widgets
- lib/presentation : pages legacy (explore/network, notifications) avec BLoC
- lib/shared/design_system : UnionFlow Design System v2 (tokens, components)
  + MD3 tokens + module_colors par feature
- lib/shared/widgets : widgets transversaux (core_card, core_shimmer,
  error_widget, loading_widget, powered_by_lions_dev, etc.)
- lib/shared/constants + utils
2026-04-15 20:27:23 +00:00
dahoud
744faa3a9c feat(features): refontes onboarding/organizations/profile/reports/settings/solidarity
- onboarding : datasource souscription, models formule/status, bloc complet
- organizations : bloc orgs + switcher + types bloc, models, pages edit/create
- profile : bloc complet avec change password, delete account, preferences
- reports : bloc avec DashboardReports + ScheduleReports + GenerateReport
- settings : language, privacy, feedback pages
- solidarity : bloc complet demandes d'aide (CRUD, approuver, rejeter)
2026-04-15 20:27:12 +00:00
dahoud
dbf6a972ba feat(features): refontes explore/feed/finance_workflow/help/logs/members/notifications
- explore + feed : pages de découverte (réseau, fil d'actualité)
- finance_workflow : approvals bloc + budgets bloc + dialogs
- help : support page avec FAQ + contact
- logs : monitoring bloc avec metrics + alerts + searchLogs
- members : recherche avancée, bulk actions, bloc complet, import/export
- notifications : bloc + page
2026-04-15 20:27:01 +00:00
dahoud
120434aba0 feat(features): refontes adhesions/admin/auth/backup/contributions/dashboard/epargne/events
- adhesions : bloc complet avec events/states/model, dialogs paiement/rejet
- admin : users bloc, user management list/detail pages
- authentication : bloc + keycloak auth service + webview
- backup : bloc complet, repository, models
- contributions : bloc + widgets + export
- dashboard : widgets connectés (activities, events, notifications, search)
  + charts + monitoring + shortcuts
- epargne : repository, transactions, dialogs
- events : bloc complet, pages (detail, connected, wrapper), models
2026-04-15 20:26:48 +00:00
dahoud
45dcd2171e feat(communication): module messagerie unifié + contact policies + blocages
Aligné avec le backend MessagingResource :
- Nouveau module communication (conversations, messages, participants)
- Respect des ContactPolicy (qui peut parler à qui par rôle)
- Gestion MemberBlock (blocages individuels)
- UI : conversations list, conversation detail, broadcast, tiles
- BLoC : MessagingBloc avec events (envoyer, démarrer conversation rôle, etc.)
2026-04-15 20:26:35 +00:00
dahoud
07b8488714 feat(core): refonte architecture transverse (cache, network, websocket, DI)
- lib/app : app.dart, router mis à jour (routes nouveaux modules)
- lib/core/cache : cache_service + cached_datasource_decorator
- lib/core/network : api_client, offline_manager, retry_policy
- lib/core/websocket : websocket service (reconnexion exponentielle, heartbeat)
- lib/core/di : injection + register_module
- lib/core/storage : pending_operations_store (offline support)
- lib/core/navigation : main_navigation_layout (onglets par rôle)
- lib/core/config : environment, lcb_ft_constants
- lib/core/utils : error_formatter, validators
- pubspec.yaml/lock : dépendances mises à jour
2026-04-15 20:26:20 +00:00
dahoud
3a2c8a808f chore(platform): assets app + config Android/iOS + screenshots
- Icônes app (hdpi/mdpi/xhdpi/xxhdpi/xxxhdpi + iOS AppIcon) régénérées
- Launch screens Android + iOS actualisés
- ios/Runner : Info.plist, LaunchScreen.storyboard, project.pbxproj
- android/gradle.properties, drawables, styles (values + values-night)
- assets/images : branding/logo
- Screenshots Flutter (flutter_01-04.png) + script PowerShell start-emulators
2026-04-15 20:26:11 +00:00
273 changed files with 7503 additions and 4519 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#0A0D1A</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#FFFFFF</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@@ -1,4 +1,6 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError -Djava.net.preferIPv4Stack=true
org.gradle.parallel=false
org.gradle.workers.max=1
android.useAndroidX=true
android.enableJetifier=true

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
flutter_01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

0
flutter_02.png Normal file
View File

BIN
flutter_03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
flutter_04.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

View File

@@ -427,7 +427,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -484,7 +484,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1016 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "darkbackground.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,23 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 782 KiB

View File

@@ -16,13 +16,19 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints>
</view>
</viewController>
@@ -32,6 +38,7 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchImage" width="1024" height="1024"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>

View File

@@ -66,5 +66,7 @@
</array>
</dict>
</array>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>

View File

@@ -46,7 +46,13 @@ class UnionFlowApp extends StatelessWidget {
],
child: Consumer2<LocaleProvider, ThemeProvider>(
builder: (context, locale, theme, child) {
return MaterialApp(
return BlocListener<AuthBloc, AuthState>(
listenWhen: (prev, curr) =>
curr is AuthAuthenticated && prev is! AuthAuthenticated,
listener: (context, _) {
context.read<OrgSwitcherBloc>().add(const OrgSwitcherLoadRequested());
},
child: MaterialApp(
title: 'UnionFlow',
debugShowCheckedModeBanner: false,
scaffoldMessengerKey: UnionFlowApp.scaffoldMessengerKey,
@@ -79,6 +85,7 @@ class UnionFlowApp extends StatelessWidget {
child: child ?? const SizedBox(),
);
},
),
);
},
),

View File

@@ -20,7 +20,7 @@ import '../../features/adhesions/presentation/pages/adhesions_page_wrapper.dart'
import '../../features/settings/presentation/pages/system_settings_page.dart';
import '../../features/dashboard/presentation/pages/advanced_dashboard_page.dart';
import '../../features/admin/presentation/pages/user_management_page.dart';
import '../../features/communication/presentation/pages/conversations_page.dart';
import '../../features/communication/presentation/pages/conversations_page_wrapper.dart';
import '../../features/finance_workflow/presentation/pages/pending_approvals_page.dart';
import '../../features/finance_workflow/presentation/pages/budgets_list_page.dart';
import '../../core/navigation/main_navigation_layout.dart';
@@ -86,7 +86,7 @@ class AppRouter {
'/reports': (context) => const ReportsPageWrapper(),
'/finances': (context) => const CotisationsPageWrapper(),
'/adhesions': (context) => const AdhesionsPageWrapper(),
'/messages': (context) => const ConversationsPage(),
'/messages': (context) => const ConversationsPageWrapper(),
'/settings': (context) => const SystemSettingsPage(),
'/analytics': (context) {
final authState = context.read<AuthBloc>().state;

View File

@@ -118,7 +118,7 @@ class _MainNavigationLayoutState extends State<MainNavigationLayout> {
statusBarIconBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: ColorTokens.background,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: SafeArea(
top: true,
bottom: false,
@@ -199,12 +199,13 @@ class _PillNavigationBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: ColorTokens.surface,
color: scheme.surface,
boxShadow: [
BoxShadow(
color: ColorTokens.shadow,
color: scheme.shadow.withOpacity(0.12),
blurRadius: 12,
offset: const Offset(0, -2),
),

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../shared/design_system/tokens/app_colors.dart';
import 'package:injectable/injectable.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../app/app.dart';
@@ -35,17 +36,7 @@ class ApiClient {
),
);
// Intercepteur de Log (Uniquement en Dev)
if (AppConfig.enableLogging) {
_dio.interceptors.add(LogInterceptor(
requestHeader: true,
requestBody: true,
responseBody: true,
logPrint: (obj) => print('🌐 [API] $obj'),
));
}
// Intercepteur de Token & Refresh automatique
// Intercepteur de Token & Refresh automatique (doit être AVANT le logger)
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
@@ -112,6 +103,16 @@ class ApiClient {
},
),
);
// Intercepteur de Log (après le token pour voir le Bearer dans les logs)
if (AppConfig.enableLogging) {
_dio.interceptors.add(LogInterceptor(
requestHeader: true,
requestBody: true,
responseBody: true,
logPrint: (obj) => print('🌐 [API] $obj'),
));
}
}
void _forceLogout() {
@@ -132,7 +133,7 @@ class ApiClient {
),
],
),
backgroundColor: Colors.orange.shade700,
backgroundColor: AppColors.warning,
duration: const Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
),

View File

@@ -77,6 +77,17 @@ abstract class WebSocketEvent {
organizationId: json['organizationId'] as String?,
);
case 'NOUVEAU_MESSAGE':
case 'MESSAGE_SUPPRIME':
case 'CONVERSATION_LUE':
return ChatMessageEvent(
eventType: eventType,
timestamp: timestamp,
data: data,
conversationId: json['conversationId'] as String?,
organizationId: json['organizationId'] as String?,
);
default:
return GenericEvent(
eventType: eventType,
@@ -144,6 +155,19 @@ class ContributionEvent extends WebSocketEvent {
});
}
class ChatMessageEvent extends WebSocketEvent {
final String? conversationId;
final String? organizationId;
ChatMessageEvent({
required super.eventType,
required super.timestamp,
required super.data,
this.conversationId,
this.organizationId,
});
}
class GenericEvent extends WebSocketEvent {
GenericEvent({
required super.eventType,

View File

@@ -41,9 +41,10 @@ class _UserManagementViewState extends State<_UserManagementView> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: UFAppBar(
title: 'Gestion des utilisateurs',
moduleGradient: ModuleColors.systemeGradient,
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 20),
@@ -51,7 +52,9 @@ class _UserManagementViewState extends State<_UserManagementView> {
),
],
),
body: Column(
body: SafeArea(
top: false,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
@@ -63,18 +66,18 @@ class _UserManagementViewState extends State<_UserManagementView> {
prefixIcon: const Icon(Icons.search, size: 18),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(RadiusTokens.md),
borderSide: const BorderSide(color: AppColors.lightBorder),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(RadiusTokens.md),
borderSide: const BorderSide(color: AppColors.lightBorder),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(RadiusTokens.md),
borderSide: const BorderSide(color: AppColors.primaryGreen),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
),
filled: true,
fillColor: AppColors.lightSurface,
fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
onSubmitted: (v) => context.read<AdminUsersBloc>().add(
@@ -131,6 +134,7 @@ class _UserManagementViewState extends State<_UserManagementView> {
),
],
),
),
);
}
@@ -168,10 +172,10 @@ class _UserManagementViewState extends State<_UserManagementView> {
],
),
),
const Icon(
Icon(
Icons.chevron_right,
size: 16,
color: AppColors.textSecondaryLight,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),

View File

@@ -167,9 +167,56 @@ class KeycloakAuthService {
return null;
}
/// Logout : révoque la session SSO côté Keycloak via le back-channel
/// (POST /logout silencieux, sans navigateur), puis purge le stockage local.
///
/// Conforme OIDC RP-Initiated Logout. Ne lève jamais d'exception : la purge
/// locale est garantie même si Keycloak est injoignable. Le statut du
/// back-channel est tracé dans les logs pour diagnostic.
Future<void> logout() async {
final refresh = await _storage.read(key: _refreshK);
if (refresh == null || refresh.isEmpty) {
AppLogger.info('KeycloakAuthService: no refresh token, skipping backchannel logout');
} else {
try {
final response = await _dio.post(
KeycloakConfig.logoutEndpoint,
data: {
'client_id': KeycloakConfig.clientId,
'refresh_token': refresh,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
// Accepte tout statut < 600 — on interprète nous-mêmes ci-dessous.
validateStatus: (_) => true,
),
);
final code = response.statusCode ?? 0;
if (code == 200 || code == 204) {
AppLogger.info('KeycloakAuthService: SSO session revoked at Keycloak (HTTP $code)');
} else if (code == 400) {
// Refresh token déjà invalide côté Keycloak → idempotent, OK.
AppLogger.info('KeycloakAuthService: refresh token already invalid (HTTP 400) — session considered revoked');
} else {
AppLogger.error(
'KeycloakAuthService: backchannel logout returned HTTP $code'
'SSO session may still be active. Body: ${response.data}',
);
}
} catch (e, st) {
AppLogger.error(
'KeycloakAuthService: backchannel logout network error — local logout will still proceed',
error: e,
stackTrace: st,
);
}
}
// Purge locale toujours effectuée — l'utilisateur "se sent" déconnecté
// immédiatement même si le serveur n'a pas pu être notifié.
await _storage.deleteAll();
AppLogger.info('KeycloakAuthService: session cleared');
AppLogger.info('KeycloakAuthService: local session cleared');
}
Future<void> _saveTokens(Map<String, dynamic> data) async {

View File

@@ -15,7 +15,7 @@ enum UserRole {
level: 100,
displayName: 'Super Administrateur',
description: 'Accès complet système et multi-organisations',
color: 0xFF6C5CE7, // Violet sophistiqué
color: 0xFF7616E8, // Violet UnionFlow (super admin)
permissions: _superAdminPermissions,
),
@@ -25,7 +25,7 @@ enum UserRole {
level: 80,
displayName: 'Administrateur',
description: 'Gestion complète de l\'organisation',
color: 0xFF0984E3, // Bleu corporate
color: 0xFF2563EB, // Bleu primaire UnionFlow
permissions: _orgAdminPermissions,
),
@@ -45,7 +45,7 @@ enum UserRole {
level: 58,
displayName: 'Consultant',
description: 'Accès consultant et conseil',
color: 0xFF6C5CE7, // Violet
color: 0xFF5297FF, // Bleu intermédiaire (consultant)
permissions: _consultantPermissions,
),
@@ -55,7 +55,7 @@ enum UserRole {
level: 52,
displayName: 'Gestionnaire RH',
description: 'Gestion des ressources humaines',
color: 0xFF0984E3, // Bleu
color: 0xFF1D4ED8, // Bleu foncé (RH)
permissions: _hrManagerPermissions,
),
@@ -65,7 +65,7 @@ enum UserRole {
level: 40,
displayName: 'Membre Actif',
description: 'Participation active aux activités',
color: 0xFF00B894, // Vert communauté
color: 0xFF22C55E, // Vert succès (membre actif)
permissions: _activeMemberPermissions,
),
@@ -75,7 +75,7 @@ enum UserRole {
level: 20,
displayName: 'Membre',
description: 'Accès aux informations de base',
color: 0xFF00CEC9, // Teal simple
color: 0xFF60A5FA, // Bleu clair (membre simple)
permissions: _simpleMemberPermissions,
),
@@ -85,7 +85,7 @@ enum UserRole {
level: 0,
displayName: 'Visiteur',
description: 'Accès aux informations publiques',
color: 0xFF6C5CE7, // Indigo accueillant
color: 0xFF94A3B8, // Gris neutre (visiteur)
permissions: _visitorPermissions,
);

View File

@@ -1,16 +1,18 @@
/// Datasource distant pour la communication (API)
/// Datasource distant pour la messagerie v4 — /api/messagerie/*
library messaging_remote_datasource;
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:injectable/injectable.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/error/exceptions.dart';
import '../../../authentication/data/datasources/keycloak_auth_service.dart';
import '../models/message_model.dart';
import '../models/conversation_model.dart';
import '../../domain/entities/message.dart';
import '../models/message_model.dart';
import '../models/contact_policy_model.dart';
import 'package:http/http.dart' as http;
@lazySingleton
class MessagingRemoteDatasource {
@@ -22,7 +24,7 @@ class MessagingRemoteDatasource {
required this.authService,
});
/// Headers HTTP avec authentification — rafraîchit le token si expiré (fix IC-03)
/// Headers HTTP avec authentification — rafraîchit le token si expiré
Future<Map<String, String>> _getHeaders() async {
final token = await authService.getValidAccessToken();
return {
@@ -32,290 +34,271 @@ class MessagingRemoteDatasource {
};
}
// === CONVERSATIONS ===
String get _base => '${AppConfig.apiBaseUrl}/api/messagerie';
Future<List<ConversationModel>> getConversations({
String? organizationId,
bool includeArchived = false,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/conversations')
.replace(queryParameters: {
if (organizationId != null) 'organisationId': organizationId,
'includeArchived': includeArchived.toString(),
});
// ── Conversations ─────────────────────────────────────────────────────────
final response = await client.get(uri, headers: await _getHeaders());
Future<List<ConversationSummaryModel>> getMesConversations() async {
final response = await client.get(
Uri.parse('$_base/conversations'),
headers: await _getHeaders(),
);
_checkAuth(response);
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList
.map((json) => ConversationModel.fromJson(json))
final list = json.decode(response.body) as List<dynamic>;
return list
.map((j) => ConversationSummaryModel.fromJson(j as Map<String, dynamic>))
.toList();
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération des conversations');
}
throw ServerException('Erreur récupération conversations (${response.statusCode})');
}
Future<ConversationModel> getConversation(String id) async {
final response = await client.get(
Uri.parse('$_base/conversations/$id'),
headers: await _getHeaders(),
);
_checkAuth(response);
if (response.statusCode == 200) {
return ConversationModel.fromJson(json.decode(response.body) as Map<String, dynamic>);
}
if (response.statusCode == 404) throw NotFoundException('Conversation non trouvée');
throw ServerException('Erreur récupération conversation (${response.statusCode})');
}
Future<ConversationModel> demarrerConversationDirecte({
required String destinataireId,
required String organisationId,
String? premierMessage,
}) async {
final body = json.encode({
'destinataireId': destinataireId,
'organisationId': organisationId,
if (premierMessage != null) 'premierMessage': premierMessage,
});
final response = await client.post(
Uri.parse('$_base/conversations/directe'),
headers: await _getHeaders(),
body: body,
);
_checkAuth(response);
if (response.statusCode == 201 || response.statusCode == 200) {
return ConversationModel.fromJson(json.decode(response.body) as Map<String, dynamic>);
}
throw ServerException('Erreur démarrage conversation directe (${response.statusCode})');
}
Future<ConversationModel> demarrerConversationRole({
required String roleCible,
required String organisationId,
String? premierMessage,
}) async {
final body = json.encode({
'roleCible': roleCible,
'organisationId': organisationId,
if (premierMessage != null) 'premierMessage': premierMessage,
});
final response = await client.post(
Uri.parse('$_base/conversations/role'),
headers: await _getHeaders(),
body: body,
);
_checkAuth(response);
if (response.statusCode == 201 || response.statusCode == 200) {
return ConversationModel.fromJson(json.decode(response.body) as Map<String, dynamic>);
}
throw ServerException('Erreur démarrage conversation rôle (${response.statusCode})');
}
Future<void> archiverConversation(String id) async {
final response = await client.delete(
Uri.parse('$_base/conversations/$id'),
headers: await _getHeaders(),
);
_checkAuth(response);
if (response.statusCode != 200 && response.statusCode != 204) {
throw ServerException('Erreur archivage conversation (${response.statusCode})');
}
}
Future<ConversationModel> getConversationById(String conversationId) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/conversations/$conversationId');
// ── Messages ──────────────────────────────────────────────────────────────
Future<MessageModel> envoyerMessage(
String conversationId, {
required String typeMessage,
String? contenu,
String? urlFichier,
int? dureeAudio,
String? messageParentId,
}) async {
final body = json.encode({
'typeMessage': typeMessage,
if (contenu != null) 'contenu': contenu,
if (urlFichier != null) 'urlFichier': urlFichier,
if (dureeAudio != null) 'dureeAudio': dureeAudio,
if (messageParentId != null) 'messageParentId': messageParentId,
});
final response = await client.post(
Uri.parse('$_base/conversations/$conversationId/messages'),
headers: await _getHeaders(),
body: body,
);
_checkAuth(response);
if (response.statusCode == 201 || response.statusCode == 200) {
return MessageModel.fromJson(json.decode(response.body) as Map<String, dynamic>);
}
throw ServerException('Erreur envoi message (${response.statusCode})');
}
Future<List<MessageModel>> getMessages(String conversationId, {int page = 0}) async {
final uri = Uri.parse('$_base/conversations/$conversationId/messages')
.replace(queryParameters: {'page': page.toString()});
final response = await client.get(uri, headers: await _getHeaders());
_checkAuth(response);
if (response.statusCode == 200) {
return ConversationModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 404) {
throw NotFoundException('Conversation non trouvée');
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération de la conversation');
final list = json.decode(response.body) as List<dynamic>;
return list
.map((j) => MessageModel.fromJson(j as Map<String, dynamic>))
.toList();
}
throw ServerException('Erreur récupération messages (${response.statusCode})');
}
Future<ConversationModel> createConversation({
required String name,
required List<String> participantIds,
String? organizationId,
String? description,
}) async {
final uri =
Uri.parse('${AppConfig.apiBaseUrl}/api/conversations');
final body = json.encode({
'name': name,
'participantIds': participantIds,
'type': 'GROUP', // Default to GROUP for multi-participant conversations
if (organizationId != null) 'organisationId': organizationId,
if (description != null) 'description': description,
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 201 || response.statusCode == 200) {
return ConversationModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la création de la conversation');
}
}
// === MESSAGES ===
Future<List<MessageModel>> getMessages({
required String conversationId,
int? limit,
String? beforeMessageId,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messages')
.replace(queryParameters: {
'conversationId': conversationId,
if (limit != null) 'limit': limit.toString(),
// beforeMessageId not supported by backend yet, omit
});
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList.map((json) => MessageModel.fromJson(json)).toList();
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération des messages');
}
}
Future<MessageModel> sendMessage({
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messages');
final body = json.encode({
'conversationId': conversationId,
'content': content,
if (attachments != null) 'attachments': attachments,
'priority': priority.name.toUpperCase(),
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 201 || response.statusCode == 200) {
return MessageModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de l\'envoi du message');
}
}
Future<MessageModel> sendBroadcast({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messaging/broadcast');
final body = json.encode({
'organizationId': organizationId,
'subject': subject,
'content': content,
'priority': priority.name,
if (attachments != null) 'attachments': attachments,
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 201 || response.statusCode == 200) {
return MessageModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 403) {
throw ForbiddenException('Permission insuffisante pour envoyer un broadcast');
} else {
throw ServerException('Erreur lors de l\'envoi du broadcast');
}
}
// === CONVERSATION ACTIONS ===
Future<void> archiveConversation(String conversationId, {bool archive = true}) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/conversations/$conversationId/archive')
.replace(queryParameters: {'archive': archive.toString()});
final response = await client.put(uri, headers: await _getHeaders());
if (response.statusCode != 200 && response.statusCode != 204) {
if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de l\'archivage de la conversation');
}
}
}
Future<void> markConversationAsRead(String conversationId) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/conversations/$conversationId/mark-read');
final response = await client.put(uri, headers: await _getHeaders());
if (response.statusCode != 200 && response.statusCode != 204) {
if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors du marquage de la conversation comme lue');
}
}
}
Future<void> toggleMuteConversation(String conversationId) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/conversations/$conversationId/toggle-mute');
final response = await client.put(uri, headers: await _getHeaders());
if (response.statusCode != 200 && response.statusCode != 204) {
if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors du toggle mute de la conversation');
}
}
}
Future<void> togglePinConversation(String conversationId) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/conversations/$conversationId/toggle-pin');
final response = await client.put(uri, headers: await _getHeaders());
if (response.statusCode != 200 && response.statusCode != 204) {
if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors du toggle pin de la conversation');
}
}
}
// === MESSAGE ACTIONS ===
Future<MessageModel> editMessage({
required String messageId,
required String newContent,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messages/$messageId');
final body = json.encode({'content': newContent});
Future<void> marquerLu(String conversationId) async {
final response = await client.put(
uri,
Uri.parse('$_base/conversations/$conversationId/lire'),
headers: await _getHeaders(),
);
_checkAuth(response);
if (response.statusCode != 200 && response.statusCode != 204) {
throw ServerException('Erreur marquage lu (${response.statusCode})');
}
}
Future<void> supprimerMessage(String conversationId, String messageId) async {
final response = await client.delete(
Uri.parse('$_base/conversations/$conversationId/messages/$messageId'),
headers: await _getHeaders(),
);
_checkAuth(response);
if (response.statusCode != 200 && response.statusCode != 204) {
throw ServerException('Erreur suppression message (${response.statusCode})');
}
}
// ── Blocages ──────────────────────────────────────────────────────────────
Future<void> bloquerMembre({
required String membreABloquerId,
String? organisationId,
String? raison,
}) async {
final body = json.encode({
'membreABloquerId': membreABloquerId,
if (organisationId != null) 'organisationId': organisationId,
if (raison != null) 'raison': raison,
});
final response = await client.post(
Uri.parse('$_base/blocages'),
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 200) {
return MessageModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 404) {
throw NotFoundException('Message non trouvé');
} else {
throw ServerException('Erreur lors de l\'édition du message');
_checkAuth(response);
if (response.statusCode != 200 && response.statusCode != 204) {
throw ServerException('Erreur blocage membre (${response.statusCode})');
}
}
Future<void> deleteMessage(String messageId) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messages/$messageId');
Future<void> debloquerMembre(String membreId, {String? organisationId}) async {
final uri = Uri.parse('$_base/blocages/$membreId').replace(
queryParameters: {
if (organisationId != null) 'organisationId': organisationId,
},
);
final response = await client.delete(uri, headers: await _getHeaders());
_checkAuth(response);
if (response.statusCode != 200 && response.statusCode != 204) {
if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 404) {
throw NotFoundException('Message non trouvé');
} else {
throw ServerException('Erreur lors de la suppression du message');
}
}
}
Future<void> markMessageAsRead(String messageId) async {
// Backend has no per-message read endpoint — use markConversationAsRead
if (AppConfig.enableLogging) {
debugPrint('[Messaging] markMessageAsRead ignored (no per-message endpoint), messageId=$messageId');
throw ServerException('Erreur déblocage membre (${response.statusCode})');
}
}
Future<int> getUnreadCount({String? organizationId}) async {
try {
final conversations = await getConversations(organizationId: organizationId);
return conversations.fold<int>(0, (sum, c) => sum + c.unreadCount);
} catch (_) {
return 0;
}
Future<List<Map<String, dynamic>>> getMesBlocages() async {
final response = await client.get(
Uri.parse('$_base/blocages'),
headers: await _getHeaders(),
);
_checkAuth(response);
if (response.statusCode == 200) {
final list = json.decode(response.body) as List<dynamic>;
return list.map((j) => j as Map<String, dynamic>).toList();
}
throw ServerException('Erreur récupération blocages (${response.statusCode})');
}
// ── Politique de communication ────────────────────────────────────────────
Future<ContactPolicyModel> getPolitique(String organisationId) async {
final response = await client.get(
Uri.parse('$_base/politique/$organisationId'),
headers: await _getHeaders(),
);
_checkAuth(response);
if (response.statusCode == 200) {
return ContactPolicyModel.fromJson(json.decode(response.body) as Map<String, dynamic>);
}
throw ServerException('Erreur récupération politique (${response.statusCode})');
}
Future<ContactPolicyModel> mettreAJourPolitique(
String organisationId, {
required String typePolitique,
required bool autoriserMembreVersMembre,
required bool autoriserMembreVersRole,
required bool autoriserNotesVocales,
}) async {
final body = json.encode({
'typePolitique': typePolitique,
'autoriserMembreVersMembre': autoriserMembreVersMembre,
'autoriserMembreVersRole': autoriserMembreVersRole,
'autoriserNotesVocales': autoriserNotesVocales,
});
final response = await client.put(
Uri.parse('$_base/politique/$organisationId'),
headers: await _getHeaders(),
body: body,
);
_checkAuth(response);
if (response.statusCode == 200) {
return ContactPolicyModel.fromJson(json.decode(response.body) as Map<String, dynamic>);
}
throw ServerException('Erreur mise à jour politique (${response.statusCode})');
}
// ── Helpers ────────────────────────────────────────────────────────────────
void _checkAuth(http.Response response) {
if (response.statusCode == 401) throw UnauthorizedException();
if (response.statusCode == 403) throw ForbiddenException('Permission insuffisante');
}
}

View File

@@ -0,0 +1,27 @@
/// Modèle de données ContactPolicy v4 avec désérialisation JSON
library contact_policy_model;
import '../../domain/entities/contact_policy.dart';
/// Modèle ContactPolicy v4
class ContactPolicyModel extends ContactPolicy {
const ContactPolicyModel({
super.id,
super.organisationId,
required super.typePolitique,
super.autoriserMembreVersMembre,
super.autoriserMembreVersRole,
super.autoriserNotesVocales,
});
factory ContactPolicyModel.fromJson(Map<String, dynamic> json) {
return ContactPolicyModel(
id: json['id']?.toString(),
organisationId: json['organisationId']?.toString(),
typePolitique: json['typePolitique']?.toString() ?? 'OUVERT',
autoriserMembreVersMembre: json['autoriserMembreVersMembre'] == true,
autoriserMembreVersRole: json['autoriserMembreVersRole'] == true,
autoriserNotesVocales: json['autoriserNotesVocales'] == true,
);
}
}

View File

@@ -1,70 +1,110 @@
/// Model de données Conversation avec sérialisation JSON
/// Modèles de données Conversation v4 avec sérialisation JSON
library conversation_model;
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/conversation.dart';
import '../../domain/entities/message.dart';
import 'message_model.dart';
part 'conversation_model.g.dart';
@JsonSerializable(explicitToJson: true)
class ConversationModel extends Conversation {
@JsonKey(
fromJson: _messageFromJson,
toJson: _messageToJson,
)
@override
final Message? lastMessage;
const ConversationModel({
/// Modèle de résumé de conversation (liste)
class ConversationSummaryModel extends ConversationSummary {
const ConversationSummaryModel({
required super.id,
required super.name,
super.description,
required super.type,
required super.participantIds,
super.organizationId,
this.lastMessage,
super.unreadCount,
super.isMuted,
super.isPinned,
super.isArchived,
required super.createdAt,
super.updatedAt,
super.avatarUrl,
super.metadata,
}) : super(lastMessage: lastMessage);
required super.typeConversation,
required super.titre,
required super.statut,
super.dernierMessageApercu,
super.dernierMessageType,
super.dernierMessageAt,
super.nonLus,
super.organisationId,
});
static Message? _messageFromJson(Map<String, dynamic>? json) =>
json == null ? null : MessageModel.fromJson(json);
static Map<String, dynamic>? _messageToJson(Message? message) =>
message == null ? null : MessageModel.fromEntity(message).toJson();
factory ConversationModel.fromJson(Map<String, dynamic> json) =>
_$ConversationModelFromJson(json);
Map<String, dynamic> toJson() => _$ConversationModelToJson(this);
factory ConversationModel.fromEntity(Conversation conversation) {
return ConversationModel(
id: conversation.id,
name: conversation.name,
description: conversation.description,
type: conversation.type,
participantIds: conversation.participantIds,
organizationId: conversation.organizationId,
lastMessage: conversation.lastMessage,
unreadCount: conversation.unreadCount,
isMuted: conversation.isMuted,
isPinned: conversation.isPinned,
isArchived: conversation.isArchived,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
avatarUrl: conversation.avatarUrl,
metadata: conversation.metadata,
factory ConversationSummaryModel.fromJson(Map<String, dynamic> json) {
return ConversationSummaryModel(
id: json['id']?.toString() ?? '',
typeConversation: json['typeConversation']?.toString() ?? 'DIRECTE',
titre: json['titre']?.toString() ?? '',
statut: json['statut']?.toString() ?? 'ACTIVE',
dernierMessageApercu: json['dernierMessageApercu']?.toString(),
dernierMessageType: json['dernierMessageType']?.toString(),
dernierMessageAt: json['dernierMessageAt'] != null
? DateTime.tryParse(json['dernierMessageAt'].toString())
: null,
nonLus: _parseInt(json['nonLus']),
organisationId: json['organisationId']?.toString(),
);
}
Conversation toEntity() => this;
}
/// Modèle de participant
class ConversationParticipantModel extends ConversationParticipant {
const ConversationParticipantModel({
required super.membreId,
super.prenom,
super.nom,
super.roleDansConversation,
super.luJusqua,
});
factory ConversationParticipantModel.fromJson(Map<String, dynamic> json) {
return ConversationParticipantModel(
membreId: json['membreId']?.toString() ?? '',
prenom: json['prenom']?.toString(),
nom: json['nom']?.toString(),
roleDansConversation: json['roleDansConversation']?.toString(),
luJusqua: json['luJusqua'] != null
? DateTime.tryParse(json['luJusqua'].toString())
: null,
);
}
}
/// Modèle de conversation complète (détail)
class ConversationModel extends Conversation {
const ConversationModel({
required super.id,
required super.typeConversation,
required super.titre,
required super.statut,
super.organisationId,
super.organisationNom,
super.dateCreation,
super.nombreMessages,
super.participants,
super.messages,
super.nonLus,
super.roleCible,
});
factory ConversationModel.fromJson(Map<String, dynamic> json) {
final participantsJson = json['participants'] as List<dynamic>? ?? [];
final messagesJson = json['messages'] as List<dynamic>? ?? [];
return ConversationModel(
id: json['id']?.toString() ?? '',
typeConversation: json['typeConversation']?.toString() ?? 'DIRECTE',
titre: json['titre']?.toString() ?? '',
statut: json['statut']?.toString() ?? 'ACTIVE',
organisationId: json['organisationId']?.toString(),
organisationNom: json['organisationNom']?.toString(),
dateCreation: json['dateCreation'] != null
? DateTime.tryParse(json['dateCreation'].toString())
: null,
nombreMessages: _parseInt(json['nombreMessages']),
participants: participantsJson
.map((p) => ConversationParticipantModel.fromJson(p as Map<String, dynamic>))
.toList(),
messages: messagesJson
.map((m) => MessageModel.fromJson(m as Map<String, dynamic>))
.toList(),
nonLus: _parseInt(json['nonLus']),
roleCible: json['roleCible']?.toString(),
);
}
}
int _parseInt(dynamic value) {
if (value == null) return 0;
if (value is int) return value;
if (value is double) return value.toInt();
return int.tryParse(value.toString()) ?? 0;
}

View File

@@ -1,57 +1,2 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'conversation_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ConversationModel _$ConversationModelFromJson(Map<String, dynamic> json) =>
ConversationModel(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String?,
type: $enumDecode(_$ConversationTypeEnumMap, json['type']),
participantIds: (json['participantIds'] as List<dynamic>)
.map((e) => e as String)
.toList(),
organizationId: json['organizationId'] as String?,
lastMessage: ConversationModel._messageFromJson(
json['lastMessage'] as Map<String, dynamic>?),
unreadCount: (json['unreadCount'] as num?)?.toInt() ?? 0,
isMuted: json['isMuted'] as bool? ?? false,
isPinned: json['isPinned'] as bool? ?? false,
isArchived: json['isArchived'] as bool? ?? false,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: json['updatedAt'] == null
? null
: DateTime.parse(json['updatedAt'] as String),
avatarUrl: json['avatarUrl'] as String?,
metadata: json['metadata'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$ConversationModelToJson(ConversationModel instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'description': instance.description,
'type': _$ConversationTypeEnumMap[instance.type]!,
'participantIds': instance.participantIds,
'organizationId': instance.organizationId,
'unreadCount': instance.unreadCount,
'isMuted': instance.isMuted,
'isPinned': instance.isPinned,
'isArchived': instance.isArchived,
'createdAt': instance.createdAt.toIso8601String(),
'updatedAt': instance.updatedAt?.toIso8601String(),
'avatarUrl': instance.avatarUrl,
'metadata': instance.metadata,
'lastMessage': ConversationModel._messageToJson(instance.lastMessage),
};
const _$ConversationTypeEnumMap = {
ConversationType.individual: 'individual',
ConversationType.group: 'group',
ConversationType.broadcast: 'broadcast',
ConversationType.announcement: 'announcement',
};
// Modèles v4 : désérialisation manuelle, code generation non utilisé.

View File

@@ -1,83 +1,48 @@
/// Model de données Message avec sérialisation JSON
/// Modèle de données Message v4 avec sérialisation JSON
library message_model;
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/message.dart';
part 'message_model.g.dart';
@JsonSerializable(explicitToJson: true)
/// Modèle Message v4
class MessageModel extends Message {
const MessageModel({
required super.id,
required super.conversationId,
required super.senderId,
required super.senderName,
super.senderAvatar,
required super.content,
required super.type,
required super.status,
super.priority,
required super.recipientIds,
super.recipientRoles,
super.organizationId,
required super.createdAt,
super.readAt,
super.metadata,
super.attachments,
super.isEdited,
super.editedAt,
super.isDeleted,
required super.typeMessage,
super.contenu,
super.urlFichier,
super.dureeAudio,
super.supprime,
super.expediteurId,
super.expediteurNom,
super.expediteurPrenom,
super.messageParentId,
super.messageParentApercu,
super.dateEnvoi,
});
factory MessageModel.fromJson(Map<String, dynamic> json) =>
_$MessageModelFromJson(json);
Map<String, dynamic> toJson() => _$MessageModelToJson(this);
factory MessageModel.fromEntity(Message message) {
factory MessageModel.fromJson(Map<String, dynamic> json) {
return MessageModel(
id: message.id,
conversationId: message.conversationId,
senderId: message.senderId,
senderName: message.senderName,
senderAvatar: message.senderAvatar,
content: message.content,
type: message.type,
status: message.status,
priority: message.priority,
recipientIds: message.recipientIds,
recipientRoles: message.recipientRoles,
organizationId: message.organizationId,
createdAt: message.createdAt,
readAt: message.readAt,
metadata: message.metadata,
attachments: message.attachments,
isEdited: message.isEdited,
editedAt: message.editedAt,
isDeleted: message.isDeleted,
id: json['id']?.toString() ?? '',
typeMessage: json['typeMessage']?.toString() ?? 'TEXTE',
contenu: json['contenu']?.toString(),
urlFichier: json['urlFichier']?.toString(),
dureeAudio: _parseInt(json['dureeAudio']),
supprime: json['supprime'] == true,
expediteurId: json['expediteurId']?.toString(),
expediteurNom: json['expediteurNom']?.toString(),
expediteurPrenom: json['expediteurPrenom']?.toString(),
messageParentId: json['messageParentId']?.toString(),
messageParentApercu: json['messageParentApercu']?.toString(),
dateEnvoi: json['dateEnvoi'] != null
? DateTime.tryParse(json['dateEnvoi'].toString())
: null,
);
}
}
Message toEntity() => Message(
id: id,
conversationId: conversationId,
senderId: senderId,
senderName: senderName,
senderAvatar: senderAvatar,
content: content,
type: type,
status: status,
priority: priority,
recipientIds: recipientIds,
recipientRoles: recipientRoles,
organizationId: organizationId,
createdAt: createdAt,
readAt: readAt,
metadata: metadata,
attachments: attachments,
isEdited: isEdited,
editedAt: editedAt,
isDeleted: isDeleted,
);
int? _parseInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is double) return value.toInt();
return int.tryParse(value.toString());
}

View File

@@ -1,84 +1,2 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'message_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MessageModel _$MessageModelFromJson(Map<String, dynamic> json) => MessageModel(
id: json['id'] as String,
conversationId: json['conversationId'] as String,
senderId: json['senderId'] as String,
senderName: json['senderName'] as String,
senderAvatar: json['senderAvatar'] as String?,
content: json['content'] as String,
type: $enumDecode(_$MessageTypeEnumMap, json['type']),
status: $enumDecode(_$MessageStatusEnumMap, json['status']),
priority:
$enumDecodeNullable(_$MessagePriorityEnumMap, json['priority']) ??
MessagePriority.normal,
recipientIds: (json['recipientIds'] as List<dynamic>)
.map((e) => e as String)
.toList(),
recipientRoles: (json['recipientRoles'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
organizationId: json['organizationId'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
readAt: json['readAt'] == null
? null
: DateTime.parse(json['readAt'] as String),
metadata: json['metadata'] as Map<String, dynamic>?,
attachments: (json['attachments'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
isEdited: json['isEdited'] as bool? ?? false,
editedAt: json['editedAt'] == null
? null
: DateTime.parse(json['editedAt'] as String),
isDeleted: json['isDeleted'] as bool? ?? false,
);
Map<String, dynamic> _$MessageModelToJson(MessageModel instance) =>
<String, dynamic>{
'id': instance.id,
'conversationId': instance.conversationId,
'senderId': instance.senderId,
'senderName': instance.senderName,
'senderAvatar': instance.senderAvatar,
'content': instance.content,
'type': _$MessageTypeEnumMap[instance.type]!,
'status': _$MessageStatusEnumMap[instance.status]!,
'priority': _$MessagePriorityEnumMap[instance.priority]!,
'recipientIds': instance.recipientIds,
'recipientRoles': instance.recipientRoles,
'organizationId': instance.organizationId,
'createdAt': instance.createdAt.toIso8601String(),
'readAt': instance.readAt?.toIso8601String(),
'metadata': instance.metadata,
'attachments': instance.attachments,
'isEdited': instance.isEdited,
'editedAt': instance.editedAt?.toIso8601String(),
'isDeleted': instance.isDeleted,
};
const _$MessageTypeEnumMap = {
MessageType.individual: 'individual',
MessageType.broadcast: 'broadcast',
MessageType.targeted: 'targeted',
MessageType.system: 'system',
};
const _$MessageStatusEnumMap = {
MessageStatus.sent: 'sent',
MessageStatus.delivered: 'delivered',
MessageStatus.read: 'read',
MessageStatus.failed: 'failed',
};
const _$MessagePriorityEnumMap = {
MessagePriority.normal: 'normal',
MessagePriority.high: 'high',
MessagePriority.urgent: 'urgent',
};
// Modèles v4 : désérialisation manuelle, code generation non utilisé.

View File

@@ -1,416 +1,252 @@
/// Implémentation du repository de messagerie
/// Implémentation du repository de messagerie v4
library messaging_repository_impl;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/network/network_info.dart';
import '../../domain/entities/conversation.dart';
import '../../domain/entities/message.dart';
import '../../domain/entities/message_template.dart';
import '../../domain/entities/contact_policy.dart';
import '../../domain/repositories/messaging_repository.dart';
import '../datasources/messaging_remote_datasource.dart';
@LazySingleton(as: MessagingRepository)
class MessagingRepositoryImpl implements MessagingRepository {
final MessagingRemoteDatasource remoteDatasource;
final NetworkInfo networkInfo;
MessagingRepositoryImpl({
required this.remoteDatasource,
required this.networkInfo,
});
MessagingRepositoryImpl({required this.remoteDatasource});
@override
Future<Either<Failure, List<Conversation>>> getConversations({
String? organizationId,
bool includeArchived = false,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
Future<List<ConversationSummary>> getMesConversations() async {
try {
final conversations = await remoteDatasource.getConversations(
organizationId: organizationId,
includeArchived: includeArchived,
);
return Right(conversations);
return await remoteDatasource.getMesConversations();
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
throw Exception(e.message);
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
throw Exception('Erreur inattendue: $e');
}
}
@override
Future<Either<Failure, Conversation>> getConversationById(
String conversationId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
Future<Conversation> getConversation(String conversationId) async {
try {
final conversation =
await remoteDatasource.getConversationById(conversationId);
return Right(conversation);
return await remoteDatasource.getConversation(conversationId);
} on NotFoundException {
return Left(NotFoundFailure('Conversation non trouvée'));
throw Exception('Conversation non trouvée');
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
throw Exception(e.message);
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
throw Exception('Erreur inattendue: $e');
}
}
@override
Future<Either<Failure, Conversation>> createConversation({
required String name,
required List<String> participantIds,
String? organizationId,
String? description,
Future<Conversation> demarrerConversationDirecte({
required String destinataireId,
required String organisationId,
String? premierMessage,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final conversation = await remoteDatasource.createConversation(
name: name,
participantIds: participantIds,
organizationId: organizationId,
description: description,
return await remoteDatasource.demarrerConversationDirecte(
destinataireId: destinataireId,
organisationId: organisationId,
premierMessage: premierMessage,
);
return Right(conversation);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
throw Exception(e.message);
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
throw Exception('Erreur inattendue: $e');
}
}
@override
Future<Either<Failure, List<Message>>> getMessages({
required String conversationId,
int? limit,
String? beforeMessageId,
Future<Conversation> demarrerConversationRole({
required String roleCible,
required String organisationId,
String? premierMessage,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final messages = await remoteDatasource.getMessages(
conversationId: conversationId,
limit: limit,
beforeMessageId: beforeMessageId,
return await remoteDatasource.demarrerConversationRole(
roleCible: roleCible,
organisationId: organisationId,
premierMessage: premierMessage,
);
return Right(messages);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
throw Exception(e.message);
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
throw Exception('Erreur inattendue: $e');
}
}
@override
Future<Either<Failure, Message>> sendMessage({
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
Future<void> archiverConversation(String conversationId) async {
try {
final message = await remoteDatasource.sendMessage(
conversationId: conversationId,
content: content,
attachments: attachments,
priority: priority,
);
return Right(message);
await remoteDatasource.archiverConversation(conversationId);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
throw Exception(e.message);
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
throw Exception('Erreur inattendue: $e');
}
}
@override
Future<Either<Failure, Message>> sendBroadcast({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
Future<Message> envoyerMessage(
String conversationId, {
required String typeMessage,
String? contenu,
String? urlFichier,
int? dureeAudio,
String? messageParentId,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final message = await remoteDatasource.sendBroadcast(
organizationId: organizationId,
subject: subject,
content: content,
priority: priority,
attachments: attachments,
return await remoteDatasource.envoyerMessage(
conversationId,
typeMessage: typeMessage,
contenu: contenu,
urlFichier: urlFichier,
dureeAudio: dureeAudio,
messageParentId: messageParentId,
);
return Right(message);
} on UnauthorizedException {
throw Exception('Session expirée — veuillez vous reconnecter');
} on ForbiddenException catch (e) {
return Left(ForbiddenFailure(e.message));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
throw Exception(e.message);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
throw Exception(e.message);
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
throw Exception('Erreur inattendue: $e');
}
}
@override
Future<Either<Failure, void>> markMessageAsRead(String messageId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
Future<List<Message>> getMessages(String conversationId, {int page = 0}) async {
try {
await remoteDatasource.markMessageAsRead(messageId);
return const Right(null);
return await remoteDatasource.getMessages(conversationId, page: page);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
throw Exception(e.message);
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
throw Exception('Erreur inattendue: $e');
}
}
@override
Future<Either<Failure, int>> getUnreadCount({String? organizationId}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
Future<void> marquerLu(String conversationId) async {
try {
final count =
await remoteDatasource.getUnreadCount(organizationId: organizationId);
return Right(count);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
await remoteDatasource.marquerLu(conversationId);
} catch (_) {
// Non-bloquant — ignorer les erreurs de marquage
}
}
// === CONVERSATION ACTIONS ===
@override
Future<Either<Failure, void>> archiveConversation(String conversationId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
Future<void> supprimerMessage(String conversationId, String messageId) async {
try {
await remoteDatasource.archiveConversation(conversationId);
return const Right(null);
await remoteDatasource.supprimerMessage(conversationId, messageId);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
throw Exception(e.message);
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
throw Exception('Erreur inattendue: $e');
}
}
@override
Future<Either<Failure, Message>> sendTargetedMessage({
required String organizationId,
required List<String> targetRoles,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
Future<void> bloquerMembre({
required String membreABloquerId,
String? organisationId,
String? raison,
}) async {
// TODO: Backend needs specific endpoint for targeted messages
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> markConversationAsRead(String conversationId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
await remoteDatasource.markConversationAsRead(conversationId);
return const Right(null);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, void>> toggleMuteConversation(String conversationId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
await remoteDatasource.toggleMuteConversation(conversationId);
return const Right(null);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, void>> togglePinConversation(String conversationId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
await remoteDatasource.togglePinConversation(conversationId);
return const Right(null);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
// === MESSAGE ACTIONS ===
@override
Future<Either<Failure, Message>> editMessage({
required String messageId,
required String newContent,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final message = await remoteDatasource.editMessage(
messageId: messageId,
newContent: newContent,
await remoteDatasource.bloquerMembre(
membreABloquerId: membreABloquerId,
organisationId: organisationId,
raison: raison,
);
return Right(message);
} on NotFoundException {
return Left(NotFoundFailure('Message non trouvé'));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
throw Exception(e.message);
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
throw Exception('Erreur inattendue: $e');
}
}
@override
Future<Either<Failure, void>> deleteMessage(String messageId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
Future<void> debloquerMembre(String membreId, {String? organisationId}) async {
try {
await remoteDatasource.deleteMessage(messageId);
return const Right(null);
} on NotFoundException {
return Left(NotFoundFailure('Message non trouvé'));
await remoteDatasource.debloquerMembre(membreId, organisationId: organisationId);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
throw Exception(e.message);
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
throw Exception('Erreur inattendue: $e');
}
}
@override
Future<Either<Failure, List<MessageTemplate>>> getTemplates({
String? organizationId,
TemplateCategory? category,
Future<List<Map<String, dynamic>>> getMesBlocages() async {
try {
return await remoteDatasource.getMesBlocages();
} on UnauthorizedException {
throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) {
throw Exception(e.message);
} catch (e) {
throw Exception('Erreur inattendue: $e');
}
}
@override
Future<ContactPolicy> getPolitique(String organisationId) async {
try {
return await remoteDatasource.getPolitique(organisationId);
} on UnauthorizedException {
throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) {
throw Exception(e.message);
} catch (e) {
throw Exception('Erreur inattendue: $e');
}
}
@override
Future<ContactPolicy> mettreAJourPolitique(
String organisationId, {
required String typePolitique,
required bool autoriserMembreVersMembre,
required bool autoriserMembreVersRole,
required bool autoriserNotesVocales,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, MessageTemplate>> getTemplateById(String templateId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, MessageTemplate>> createTemplate({
required String name,
required String description,
required TemplateCategory category,
required String subject,
required String body,
List<Map<String, dynamic>>? variables,
String? organizationId,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, MessageTemplate>> updateTemplate({
required String templateId,
String? name,
String? description,
String? subject,
String? body,
bool? isActive,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> deleteTemplate(String templateId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Message>> sendFromTemplate({
required String templateId,
required Map<String, String> variables,
required List<String> recipientIds,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Map<String, dynamic>>> getMessagingStats({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
try {
return await remoteDatasource.mettreAJourPolitique(
organisationId,
typePolitique: typePolitique,
autoriserMembreVersMembre: autoriserMembreVersMembre,
autoriserMembreVersRole: autoriserMembreVersRole,
autoriserNotesVocales: autoriserNotesVocales,
);
} on UnauthorizedException {
throw Exception('Session expirée — veuillez vous reconnecter');
} on ForbiddenException catch (e) {
throw Exception(e.message);
} on ServerException catch (e) {
throw Exception(e.message);
} catch (e) {
throw Exception('Erreur inattendue: $e');
}
}
}

View File

@@ -0,0 +1,35 @@
/// Entité métier ContactPolicy v4
///
/// Correspond au DTO backend : ContactPolicyResponse
library contact_policy;
import 'package:equatable/equatable.dart';
/// Politique de communication d'une organisation
class ContactPolicy extends Equatable {
final String? id;
final String? organisationId;
final String typePolitique; // OUVERT | BUREAU_SEULEMENT | GROUPES_INTERNES
final bool autoriserMembreVersMembre;
final bool autoriserMembreVersRole;
final bool autoriserNotesVocales;
const ContactPolicy({
this.id,
this.organisationId,
required this.typePolitique,
this.autoriserMembreVersMembre = true,
this.autoriserMembreVersRole = true,
this.autoriserNotesVocales = true,
});
bool get isOuvert => typePolitique == 'OUVERT';
bool get isBureauSeulement => typePolitique == 'BUREAU_SEULEMENT';
bool get isGroupesInternes => typePolitique == 'GROUPES_INTERNES';
@override
List<Object?> get props => [
id, organisationId, typePolitique,
autoriserMembreVersMembre, autoriserMembreVersRole, autoriserNotesVocales,
];
}

View File

@@ -1,127 +1,120 @@
/// Entité métier Conversation
/// Entités métier Conversation v4
///
/// Représente une conversation (fil de messages) dans UnionFlow
/// Correspond aux DTOs backend : ConversationSummaryResponse et ConversationResponse
library conversation;
import 'package:equatable/equatable.dart';
import 'message.dart';
/// Type de conversation
enum ConversationType {
/// Conversation individuelle (1-1)
individual,
// ── Résumé de conversation (liste) ───────────────────────────────────────────
/// Conversation de groupe
group,
/// Canal broadcast (lecture seule pour la plupart)
broadcast,
/// Canal d'annonces organisation
announcement,
}
/// Entité Conversation
class Conversation extends Equatable {
/// Résumé d'une conversation pour l'affichage en liste
class ConversationSummary extends Equatable {
final String id;
final String name;
final String? description;
final ConversationType type;
final List<String> participantIds;
final String? organizationId;
final Message? lastMessage;
final int unreadCount;
final bool isMuted;
final bool isPinned;
final bool isArchived;
final DateTime createdAt;
final DateTime? updatedAt;
final String? avatarUrl;
final Map<String, dynamic>? metadata;
final String typeConversation; // DIRECTE | ROLE_CANAL | GROUPE
final String titre;
final String statut; // ACTIVE | ARCHIVEE
final String? dernierMessageApercu;
final String? dernierMessageType;
final DateTime? dernierMessageAt;
final int nonLus;
final String? organisationId;
const Conversation({
const ConversationSummary({
required this.id,
required this.name,
this.description,
required this.type,
required this.participantIds,
this.organizationId,
this.lastMessage,
this.unreadCount = 0,
this.isMuted = false,
this.isPinned = false,
this.isArchived = false,
required this.createdAt,
this.updatedAt,
this.avatarUrl,
this.metadata,
required this.typeConversation,
required this.titre,
required this.statut,
this.dernierMessageApercu,
this.dernierMessageType,
this.dernierMessageAt,
this.nonLus = 0,
this.organisationId,
});
/// Vérifie si la conversation a des messages non lus
bool get hasUnread => unreadCount > 0;
/// Vérifie si c'est une conversation individuelle
bool get isIndividual => type == ConversationType.individual;
/// Vérifie si c'est un broadcast
bool get isBroadcast => type == ConversationType.broadcast;
/// Nombre de participants
int get participantCount => participantIds.length;
/// Copie avec modifications
Conversation copyWith({
String? id,
String? name,
String? description,
ConversationType? type,
List<String>? participantIds,
String? organizationId,
Message? lastMessage,
int? unreadCount,
bool? isMuted,
bool? isPinned,
bool? isArchived,
DateTime? createdAt,
DateTime? updatedAt,
String? avatarUrl,
Map<String, dynamic>? metadata,
}) {
return Conversation(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
type: type ?? this.type,
participantIds: participantIds ?? this.participantIds,
organizationId: organizationId ?? this.organizationId,
lastMessage: lastMessage ?? this.lastMessage,
unreadCount: unreadCount ?? this.unreadCount,
isMuted: isMuted ?? this.isMuted,
isPinned: isPinned ?? this.isPinned,
isArchived: isArchived ?? this.isArchived,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
avatarUrl: avatarUrl ?? this.avatarUrl,
metadata: metadata ?? this.metadata,
);
}
bool get hasUnread => nonLus > 0;
bool get isDirecte => typeConversation == 'DIRECTE';
bool get isRoleCanal => typeConversation == 'ROLE_CANAL';
bool get isGroupe => typeConversation == 'GROUPE';
bool get isActive => statut == 'ACTIVE';
@override
List<Object?> get props => [
id,
name,
description,
type,
participantIds,
organizationId,
lastMessage,
unreadCount,
isMuted,
isPinned,
isArchived,
createdAt,
updatedAt,
avatarUrl,
metadata,
id, typeConversation, titre, statut,
dernierMessageApercu, dernierMessageType, dernierMessageAt,
nonLus, organisationId,
];
}
// ── Participant ───────────────────────────────────────────────────────────────
/// Participant dans une conversation
class ConversationParticipant extends Equatable {
final String membreId;
final String? prenom;
final String? nom;
final String? roleDansConversation;
final DateTime? luJusqua;
const ConversationParticipant({
required this.membreId,
this.prenom,
this.nom,
this.roleDansConversation,
this.luJusqua,
});
String get nomComplet {
if (prenom != null && nom != null) return '$prenom $nom';
if (prenom != null) return prenom!;
if (nom != null) return nom!;
return membreId;
}
@override
List<Object?> get props => [membreId, prenom, nom, roleDansConversation, luJusqua];
}
// ── Conversation complète (détail) ───────────────────────────────────────────
/// Conversation complète avec participants et messages
class Conversation extends Equatable {
final String id;
final String typeConversation;
final String titre;
final String statut;
final String? organisationId;
final String? organisationNom;
final DateTime? dateCreation;
final int nombreMessages;
final List<ConversationParticipant> participants;
final List<Message> messages;
final int nonLus;
final String? roleCible;
const Conversation({
required this.id,
required this.typeConversation,
required this.titre,
required this.statut,
this.organisationId,
this.organisationNom,
this.dateCreation,
this.nombreMessages = 0,
this.participants = const [],
this.messages = const [],
this.nonLus = 0,
this.roleCible,
});
bool get isDirecte => typeConversation == 'DIRECTE';
bool get isRoleCanal => typeConversation == 'ROLE_CANAL';
bool get isGroupe => typeConversation == 'GROUPE';
bool get isActive => statut == 'ACTIVE';
@override
List<Object?> get props => [
id, typeConversation, titre, statut, organisationId, organisationNom,
dateCreation, nombreMessages, participants, messages, nonLus, roleCible,
];
}

View File

@@ -1,173 +1,68 @@
/// Entité métier Message
/// Entité métier Message v4
///
/// Représente un message dans le système de communication UnionFlow
/// Correspond au DTO backend : MessageResponse
library message;
import 'package:equatable/equatable.dart';
/// Type de message
enum MessageType {
/// Message individuel (membre à membre)
individual,
/// Broadcast organisation (OrgAdmin → tous)
broadcast,
/// Message ciblé par rôle (Moderator → groupe)
targeted,
/// Notification système
system,
}
/// Statut de lecture du message
enum MessageStatus {
/// Envoyé mais non lu
sent,
/// Livré (reçu par le serveur)
delivered,
/// Lu par le destinataire
read,
/// Échec d'envoi
failed,
}
/// Priorité du message
enum MessagePriority {
/// Priorité normale
normal,
/// Priorité élevée (important)
high,
/// Priorité urgente (critique)
urgent,
}
/// Entité Message
/// Message dans une conversation
class Message extends Equatable {
final String id;
final String conversationId;
final String senderId;
final String senderName;
final String? senderAvatar;
final String content;
final MessageType type;
final MessageStatus status;
final MessagePriority priority;
final List<String> recipientIds;
final List<String>? recipientRoles;
final String? organizationId;
final DateTime createdAt;
final DateTime? readAt;
final Map<String, dynamic>? metadata;
final List<String>? attachments;
final bool isEdited;
final DateTime? editedAt;
final bool isDeleted;
final String typeMessage; // TEXTE | VOCAL | IMAGE | SYSTEME
final String? contenu;
final String? urlFichier;
final int? dureeAudio;
final bool supprime;
final String? expediteurId;
final String? expediteurNom;
final String? expediteurPrenom;
final String? messageParentId;
final String? messageParentApercu;
final DateTime? dateEnvoi;
const Message({
required this.id,
required this.conversationId,
required this.senderId,
required this.senderName,
this.senderAvatar,
required this.content,
required this.type,
required this.status,
this.priority = MessagePriority.normal,
required this.recipientIds,
this.recipientRoles,
this.organizationId,
required this.createdAt,
this.readAt,
this.metadata,
this.attachments,
this.isEdited = false,
this.editedAt,
this.isDeleted = false,
required this.typeMessage,
this.contenu,
this.urlFichier,
this.dureeAudio,
this.supprime = false,
this.expediteurId,
this.expediteurNom,
this.expediteurPrenom,
this.messageParentId,
this.messageParentApercu,
this.dateEnvoi,
});
/// Vérifie si le message a été lu
bool get isRead => status == MessageStatus.read;
String get expediteurNomComplet {
if (expediteurPrenom != null && expediteurNom != null) {
return '$expediteurPrenom $expediteurNom';
}
if (expediteurPrenom != null) return expediteurPrenom!;
if (expediteurNom != null) return expediteurNom!;
return '';
}
/// Vérifie si le message est urgent
bool get isUrgent => priority == MessagePriority.urgent;
bool get isTexte => typeMessage == 'TEXTE';
bool get isVocal => typeMessage == 'VOCAL';
bool get isImage => typeMessage == 'IMAGE';
bool get isSysteme => typeMessage == 'SYSTEME';
bool get hasParent => messageParentId != null;
/// Vérifie si le message est un broadcast
bool get isBroadcast => type == MessageType.broadcast;
/// Vérifie si le message a des pièces jointes
bool get hasAttachments => attachments != null && attachments!.isNotEmpty;
/// Copie avec modifications
Message copyWith({
String? id,
String? conversationId,
String? senderId,
String? senderName,
String? senderAvatar,
String? content,
MessageType? type,
MessageStatus? status,
MessagePriority? priority,
List<String>? recipientIds,
List<String>? recipientRoles,
String? organizationId,
DateTime? createdAt,
DateTime? readAt,
Map<String, dynamic>? metadata,
List<String>? attachments,
bool? isEdited,
DateTime? editedAt,
bool? isDeleted,
}) {
return Message(
id: id ?? this.id,
conversationId: conversationId ?? this.conversationId,
senderId: senderId ?? this.senderId,
senderName: senderName ?? this.senderName,
senderAvatar: senderAvatar ?? this.senderAvatar,
content: content ?? this.content,
type: type ?? this.type,
status: status ?? this.status,
priority: priority ?? this.priority,
recipientIds: recipientIds ?? this.recipientIds,
recipientRoles: recipientRoles ?? this.recipientRoles,
organizationId: organizationId ?? this.organizationId,
createdAt: createdAt ?? this.createdAt,
readAt: readAt ?? this.readAt,
metadata: metadata ?? this.metadata,
attachments: attachments ?? this.attachments,
isEdited: isEdited ?? this.isEdited,
editedAt: editedAt ?? this.editedAt,
isDeleted: isDeleted ?? this.isDeleted,
);
/// Texte à afficher dans la liste (aperçu)
String get apercu {
if (supprime) return '🚫 Message supprimé';
if (isVocal) return '🎙️ Note vocale${dureeAudio != null ? ' (${dureeAudio}s)' : ''}';
if (isImage) return '📷 Image';
if (isSysteme) return contenu ?? '🔔 Notification système';
return contenu ?? '';
}
@override
List<Object?> get props => [
id,
conversationId,
senderId,
senderName,
senderAvatar,
content,
type,
status,
priority,
recipientIds,
recipientRoles,
organizationId,
createdAt,
readAt,
metadata,
attachments,
isEdited,
editedAt,
isDeleted,
id, typeMessage, contenu, urlFichier, dureeAudio, supprime,
expediteurId, expediteurNom, expediteurPrenom,
messageParentId, messageParentApercu, dateEnvoi,
];
}

View File

@@ -1,145 +1,87 @@
/// Repository interface pour la communication
/// Repository interface pour la messagerie v4
///
/// Contrat de données pour les messages, conversations et templates
/// Contrat de données aligné sur l'API backend v4 (/api/messagerie/*)
library messaging_repository;
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../entities/conversation.dart';
import '../entities/message_template.dart';
import '../entities/message.dart';
import '../entities/contact_policy.dart';
/// Interface du repository de messagerie
abstract class MessagingRepository {
// === CONVERSATIONS ===
/// Récupère toutes les conversations de l'utilisateur
Future<Either<Failure, List<Conversation>>> getConversations({
String? organizationId,
bool includeArchived = false,
// ── Conversations ─────────────────────────────────────────────────────────
/// Récupère les conversations résumées de l'utilisateur connecté
Future<List<ConversationSummary>> getMesConversations();
/// Récupère la conversation complète (avec participants et messages)
Future<Conversation> getConversation(String conversationId);
/// Démarre une conversation directe avec un membre
Future<Conversation> demarrerConversationDirecte({
required String destinataireId,
required String organisationId,
String? premierMessage,
});
/// Récupère une conversation par son ID
Future<Either<Failure, Conversation>> getConversationById(String conversationId);
/// Crée une nouvelle conversation
Future<Either<Failure, Conversation>> createConversation({
required String name,
required List<String> participantIds,
String? organizationId,
String? description,
/// Démarre un canal de communication avec un rôle officiel
Future<Conversation> demarrerConversationRole({
required String roleCible,
required String organisationId,
String? premierMessage,
});
/// Archive une conversation
Future<Either<Failure, void>> archiveConversation(String conversationId);
Future<void> archiverConversation(String conversationId);
/// Marque une conversation comme lue
Future<Either<Failure, void>> markConversationAsRead(String conversationId);
// ── Messages ──────────────────────────────────────────────────────────────
/// Mute/démute une conversation
Future<Either<Failure, void>> toggleMuteConversation(String conversationId);
/// Pin/unpin une conversation
Future<Either<Failure, void>> togglePinConversation(String conversationId);
// === MESSAGES ===
/// Récupère les messages d'une conversation
Future<Either<Failure, List<Message>>> getMessages({
required String conversationId,
int? limit,
String? beforeMessageId,
/// Envoie un message dans une conversation
Future<Message> envoyerMessage(
String conversationId, {
required String typeMessage,
String? contenu,
String? urlFichier,
int? dureeAudio,
String? messageParentId,
});
/// Envoie un message individuel
Future<Either<Failure, Message>> sendMessage({
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
/// Récupère l'historique des messages (paginé)
Future<List<Message>> getMessages(String conversationId, {int page = 0});
/// Marque tous les messages d'une conversation comme lus
Future<void> marquerLu(String conversationId);
/// Supprime un message (soft-delete)
Future<void> supprimerMessage(String conversationId, String messageId);
// ── Blocages ──────────────────────────────────────────────────────────────
/// Bloque un membre
Future<void> bloquerMembre({
required String membreABloquerId,
String? organisationId,
String? raison,
});
/// Envoie un broadcast à toute l'organisation
Future<Either<Failure, Message>> sendBroadcast({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
});
/// Débloque un membre
Future<void> debloquerMembre(String membreId, {String? organisationId});
/// Envoie un message ciblé par rôles
Future<Either<Failure, Message>> sendTargetedMessage({
required String organizationId,
required List<String> targetRoles,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
});
/// Récupère la liste des membres bloqués
Future<List<Map<String, dynamic>>> getMesBlocages();
/// Marque un message comme lu
Future<Either<Failure, void>> markMessageAsRead(String messageId);
// ── Politique de communication ────────────────────────────────────────────
/// Édite un message
Future<Either<Failure, Message>> editMessage({
required String messageId,
required String newContent,
});
/// Récupère la politique de communication d'une organisation
Future<ContactPolicy> getPolitique(String organisationId);
/// Supprime un message
Future<Either<Failure, void>> deleteMessage(String messageId);
// === TEMPLATES ===
/// Récupère tous les templates disponibles
Future<Either<Failure, List<MessageTemplate>>> getTemplates({
String? organizationId,
TemplateCategory? category,
});
/// Récupère un template par son ID
Future<Either<Failure, MessageTemplate>> getTemplateById(String templateId);
/// Crée un nouveau template
Future<Either<Failure, MessageTemplate>> createTemplate({
required String name,
required String description,
required TemplateCategory category,
required String subject,
required String body,
List<Map<String, dynamic>>? variables,
String? organizationId,
});
/// Met à jour un template
Future<Either<Failure, MessageTemplate>> updateTemplate({
required String templateId,
String? name,
String? description,
String? subject,
String? body,
bool? isActive,
});
/// Supprime un template
Future<Either<Failure, void>> deleteTemplate(String templateId);
/// Envoie un message à partir d'un template
Future<Either<Failure, Message>> sendFromTemplate({
required String templateId,
required Map<String, String> variables,
required List<String> recipientIds,
});
// === STATISTIQUES ===
/// Récupère le nombre de messages non lus
Future<Either<Failure, int>> getUnreadCount({String? organizationId});
/// Récupère les statistiques de communication
Future<Either<Failure, Map<String, dynamic>>> getMessagingStats({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
/// Met à jour la politique de communication (ADMIN seulement)
Future<ContactPolicy> mettreAJourPolitique(
String organisationId, {
required String typePolitique,
required bool autoriserMembreVersMembre,
required bool autoriserMembreVersRole,
required bool autoriserNotesVocales,
});
}

View File

@@ -1,9 +1,8 @@
/// Use case: Récupérer les conversations
/// Use case: Récupérer les conversations v4
library get_conversations;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/conversation.dart';
import '../repositories/messaging_repository.dart';
@@ -13,13 +12,7 @@ class GetConversations {
GetConversations(this.repository);
Future<Either<Failure, List<Conversation>>> call({
String? organizationId,
bool includeArchived = false,
}) async {
return await repository.getConversations(
organizationId: organizationId,
includeArchived: includeArchived,
);
Future<List<ConversationSummary>> call() async {
return await repository.getMesConversations();
}
}

View File

@@ -1,9 +1,8 @@
/// Use case: Récupérer les messages d'une conversation
/// Use case: Récupérer les messages d'une conversation v4
library get_messages;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../repositories/messaging_repository.dart';
@@ -13,19 +12,10 @@ class GetMessages {
GetMessages(this.repository);
Future<Either<Failure, List<Message>>> call({
Future<List<Message>> call({
required String conversationId,
int? limit,
String? beforeMessageId,
int page = 0,
}) async {
if (conversationId.isEmpty) {
return Left(ValidationFailure('ID conversation requis'));
}
return await repository.getMessages(
conversationId: conversationId,
limit: limit,
beforeMessageId: beforeMessageId,
);
return await repository.getMessages(conversationId, page: page);
}
}

Some files were not shown because too many files have changed in this diff Show More