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"?> <?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"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" /> <item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
<!-- You can insert your own image assets here --> </item>
<!-- <item> <item>
<bitmap <bitmap android:gravity="center" android:src="@drawable/splash"/>
android:gravity="center" </item>
android:src="@mipmap/launch_image" />
</item> -->
</layer-list> </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"?> <?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"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" /> <item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
<!-- You can insert your own image assets here --> </item>
<!-- <item> <item>
<bitmap <bitmap android:gravity="center" android:src="@drawable/splash"/>
android:gravity="center" </item>
android:src="@mipmap/launch_image" />
</item> -->
</layer-list> </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 <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <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> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your 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 <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <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> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your 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.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError -Djava.net.preferIPv4Stack=true
org.gradle.parallel=false
org.gradle.workers.max=1
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=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; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; 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_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -484,7 +484,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; 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_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; 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" : [ "images" : [
{ {
"idiom" : "universal",
"filename" : "LaunchImage.png", "filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x" "scale" : "1x"
}, },
{ {
"idiom" : "universal",
"filename" : "LaunchImage@2x.png", "filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"idiom" : "universal",
"filename" : "LaunchImage@3x.png", "filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x" "scale" : "3x"
} }
], ],
"info" : { "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"> <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"> <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
</imageView> <imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews> </subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints> <constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/> <constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/> <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> </constraints>
</view> </view>
</viewController> </viewController>
@@ -32,6 +38,7 @@
</scene> </scene>
</scenes> </scenes>
<resources> <resources>
<image name="LaunchImage" width="168" height="185"/> <image name="LaunchImage" width="1024" height="1024"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources> </resources>
</document> </document>

View File

@@ -1,70 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Unionflow Mobile Apps</string> <string>Unionflow Mobile Apps</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>unionflow_mobile_apps</string> <string>unionflow_mobile_apps</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string> <string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
<string>Main</string> <string>Main</string>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<!-- Permissions pour les communications --> <!-- Permissions pour les communications -->
<key>LSApplicationQueriesSchemes</key> <key>LSApplicationQueriesSchemes</key>
<array> <array>
<string>tel</string> <string>tel</string>
<string>sms</string> <string>sms</string>
<string>mailto</string> <string>mailto</string>
</array> </array>
<!-- Retour Wave : unionflow://payment --> <!-- Retour Wave : unionflow://payment -->
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
<key>CFBundleTypeRole</key> <key>CFBundleTypeRole</key>
<string>Editor</string> <string>Editor</string>
<key>CFBundleURLName</key> <key>CFBundleURLName</key>
<string>UnionFlow Payment</string> <string>UnionFlow Payment</string>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array> <array>
<string>unionflow</string> <string>unionflow</string>
</array> </array>
</dict> </dict>
</array> </array>
</dict> <key>UIStatusBarHidden</key>
<false/>
</dict>
</plist> </plist>

View File

@@ -46,7 +46,13 @@ class UnionFlowApp extends StatelessWidget {
], ],
child: Consumer2<LocaleProvider, ThemeProvider>( child: Consumer2<LocaleProvider, ThemeProvider>(
builder: (context, locale, theme, child) { 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', title: 'UnionFlow',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
scaffoldMessengerKey: UnionFlowApp.scaffoldMessengerKey, scaffoldMessengerKey: UnionFlowApp.scaffoldMessengerKey,
@@ -79,7 +85,8 @@ class UnionFlowApp extends StatelessWidget {
child: child ?? const SizedBox(), 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/settings/presentation/pages/system_settings_page.dart';
import '../../features/dashboard/presentation/pages/advanced_dashboard_page.dart'; import '../../features/dashboard/presentation/pages/advanced_dashboard_page.dart';
import '../../features/admin/presentation/pages/user_management_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/pending_approvals_page.dart';
import '../../features/finance_workflow/presentation/pages/budgets_list_page.dart'; import '../../features/finance_workflow/presentation/pages/budgets_list_page.dart';
import '../../core/navigation/main_navigation_layout.dart'; import '../../core/navigation/main_navigation_layout.dart';
@@ -86,7 +86,7 @@ class AppRouter {
'/reports': (context) => const ReportsPageWrapper(), '/reports': (context) => const ReportsPageWrapper(),
'/finances': (context) => const CotisationsPageWrapper(), '/finances': (context) => const CotisationsPageWrapper(),
'/adhesions': (context) => const AdhesionsPageWrapper(), '/adhesions': (context) => const AdhesionsPageWrapper(),
'/messages': (context) => const ConversationsPage(), '/messages': (context) => const ConversationsPageWrapper(),
'/settings': (context) => const SystemSettingsPage(), '/settings': (context) => const SystemSettingsPage(),
'/analytics': (context) { '/analytics': (context) {
final authState = context.read<AuthBloc>().state; final authState = context.read<AuthBloc>().state;

View File

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

View File

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

View File

@@ -77,6 +77,17 @@ abstract class WebSocketEvent {
organizationId: json['organizationId'] as String?, 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: default:
return GenericEvent( return GenericEvent(
eventType: eventType, 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 { class GenericEvent extends WebSocketEvent {
GenericEvent({ GenericEvent({
required super.eventType, required super.eventType,

View File

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

View File

@@ -167,9 +167,56 @@ class KeycloakAuthService {
return null; 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 { 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(); await _storage.deleteAll();
AppLogger.info('KeycloakAuthService: session cleared'); AppLogger.info('KeycloakAuthService: local session cleared');
} }
Future<void> _saveTokens(Map<String, dynamic> data) async { Future<void> _saveTokens(Map<String, dynamic> data) async {

View File

@@ -15,7 +15,7 @@ enum UserRole {
level: 100, level: 100,
displayName: 'Super Administrateur', displayName: 'Super Administrateur',
description: 'Accès complet système et multi-organisations', description: 'Accès complet système et multi-organisations',
color: 0xFF6C5CE7, // Violet sophistiqué color: 0xFF7616E8, // Violet UnionFlow (super admin)
permissions: _superAdminPermissions, permissions: _superAdminPermissions,
), ),
@@ -25,7 +25,7 @@ enum UserRole {
level: 80, level: 80,
displayName: 'Administrateur', displayName: 'Administrateur',
description: 'Gestion complète de l\'organisation', description: 'Gestion complète de l\'organisation',
color: 0xFF0984E3, // Bleu corporate color: 0xFF2563EB, // Bleu primaire UnionFlow
permissions: _orgAdminPermissions, permissions: _orgAdminPermissions,
), ),
@@ -45,7 +45,7 @@ enum UserRole {
level: 58, level: 58,
displayName: 'Consultant', displayName: 'Consultant',
description: 'Accès consultant et conseil', description: 'Accès consultant et conseil',
color: 0xFF6C5CE7, // Violet color: 0xFF5297FF, // Bleu intermédiaire (consultant)
permissions: _consultantPermissions, permissions: _consultantPermissions,
), ),
@@ -55,7 +55,7 @@ enum UserRole {
level: 52, level: 52,
displayName: 'Gestionnaire RH', displayName: 'Gestionnaire RH',
description: 'Gestion des ressources humaines', description: 'Gestion des ressources humaines',
color: 0xFF0984E3, // Bleu color: 0xFF1D4ED8, // Bleu foncé (RH)
permissions: _hrManagerPermissions, permissions: _hrManagerPermissions,
), ),
@@ -65,7 +65,7 @@ enum UserRole {
level: 40, level: 40,
displayName: 'Membre Actif', displayName: 'Membre Actif',
description: 'Participation active aux activités', description: 'Participation active aux activités',
color: 0xFF00B894, // Vert communauté color: 0xFF22C55E, // Vert succès (membre actif)
permissions: _activeMemberPermissions, permissions: _activeMemberPermissions,
), ),
@@ -75,7 +75,7 @@ enum UserRole {
level: 20, level: 20,
displayName: 'Membre', displayName: 'Membre',
description: 'Accès aux informations de base', description: 'Accès aux informations de base',
color: 0xFF00CEC9, // Teal simple color: 0xFF60A5FA, // Bleu clair (membre simple)
permissions: _simpleMemberPermissions, permissions: _simpleMemberPermissions,
), ),
@@ -85,7 +85,7 @@ enum UserRole {
level: 0, level: 0,
displayName: 'Visiteur', displayName: 'Visiteur',
description: 'Accès aux informations publiques', description: 'Accès aux informations publiques',
color: 0xFF6C5CE7, // Indigo accueillant color: 0xFF94A3B8, // Gris neutre (visiteur)
permissions: _visitorPermissions, 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; library messaging_remote_datasource;
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import '../../../../core/config/environment.dart'; import '../../../../core/config/environment.dart';
import '../../../../core/error/exceptions.dart'; import '../../../../core/error/exceptions.dart';
import '../../../authentication/data/datasources/keycloak_auth_service.dart'; import '../../../authentication/data/datasources/keycloak_auth_service.dart';
import '../models/message_model.dart';
import '../models/conversation_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 @lazySingleton
class MessagingRemoteDatasource { class MessagingRemoteDatasource {
@@ -22,7 +24,7 @@ class MessagingRemoteDatasource {
required this.authService, 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 { Future<Map<String, String>> _getHeaders() async {
final token = await authService.getValidAccessToken(); final token = await authService.getValidAccessToken();
return { return {
@@ -32,290 +34,271 @@ class MessagingRemoteDatasource {
}; };
} }
// === CONVERSATIONS === String get _base => '${AppConfig.apiBaseUrl}/api/messagerie';
Future<List<ConversationModel>> getConversations({ // ── Conversations ─────────────────────────────────────────────────────────
String? organizationId,
bool includeArchived = false,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/conversations')
.replace(queryParameters: {
if (organizationId != null) 'organisationId': organizationId,
'includeArchived': includeArchived.toString(),
});
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) { if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body); final list = json.decode(response.body) as List<dynamic>;
return jsonList return list
.map((json) => ConversationModel.fromJson(json)) .map((j) => ConversationSummaryModel.fromJson(j as Map<String, dynamic>))
.toList(); .toList();
} else if (response.statusCode == 401) { }
throw UnauthorizedException(); throw ServerException('Erreur récupération conversations (${response.statusCode})');
} else { }
throw ServerException('Erreur lors de la récupération des conversations');
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 { // ── Messages ──────────────────────────────────────────────────────────────
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/conversations/$conversationId'); 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()); final response = await client.get(uri, headers: await _getHeaders());
_checkAuth(response);
if (response.statusCode == 200) { if (response.statusCode == 200) {
return ConversationModel.fromJson(json.decode(response.body)); final list = json.decode(response.body) as List<dynamic>;
} else if (response.statusCode == 404) { return list
throw NotFoundException('Conversation non trouvée'); .map((j) => MessageModel.fromJson(j as Map<String, dynamic>))
} else if (response.statusCode == 401) { .toList();
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération de la conversation');
} }
throw ServerException('Erreur récupération messages (${response.statusCode})');
} }
Future<ConversationModel> createConversation({ Future<void> marquerLu(String conversationId) async {
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});
final response = await client.put( 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(), headers: await _getHeaders(),
body: body, body: body,
); );
if (response.statusCode == 200) { _checkAuth(response);
return MessageModel.fromJson(json.decode(response.body)); if (response.statusCode != 200 && response.statusCode != 204) {
} else if (response.statusCode == 401) { throw ServerException('Erreur blocage membre (${response.statusCode})');
throw UnauthorizedException();
} else if (response.statusCode == 404) {
throw NotFoundException('Message non trouvé');
} else {
throw ServerException('Erreur lors de l\'édition du message');
} }
} }
Future<void> deleteMessage(String messageId) async { Future<void> debloquerMembre(String membreId, {String? organisationId}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messages/$messageId'); final uri = Uri.parse('$_base/blocages/$membreId').replace(
queryParameters: {
if (organisationId != null) 'organisationId': organisationId,
},
);
final response = await client.delete(uri, headers: await _getHeaders()); final response = await client.delete(uri, headers: await _getHeaders());
_checkAuth(response);
if (response.statusCode != 200 && response.statusCode != 204) { if (response.statusCode != 200 && response.statusCode != 204) {
if (response.statusCode == 401) { throw ServerException('Erreur déblocage membre (${response.statusCode})');
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 { Future<List<Map<String, dynamic>>> getMesBlocages() async {
// Backend has no per-message read endpoint — use markConversationAsRead final response = await client.get(
if (AppConfig.enableLogging) { Uri.parse('$_base/blocages'),
debugPrint('[Messaging] markMessageAsRead ignored (no per-message endpoint), messageId=$messageId'); 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})');
} }
Future<int> getUnreadCount({String? organizationId}) async { // ── Politique de communication ────────────────────────────────────────────
try {
final conversations = await getConversations(organizationId: organizationId); Future<ContactPolicyModel> getPolitique(String organisationId) async {
return conversations.fold<int>(0, (sum, c) => sum + c.unreadCount); final response = await client.get(
} catch (_) { Uri.parse('$_base/politique/$organisationId'),
return 0; 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; library conversation_model;
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/conversation.dart'; import '../../domain/entities/conversation.dart';
import '../../domain/entities/message.dart';
import 'message_model.dart'; import 'message_model.dart';
part 'conversation_model.g.dart'; /// Modèle de résumé de conversation (liste)
class ConversationSummaryModel extends ConversationSummary {
@JsonSerializable(explicitToJson: true) const ConversationSummaryModel({
class ConversationModel extends Conversation {
@JsonKey(
fromJson: _messageFromJson,
toJson: _messageToJson,
)
@override
final Message? lastMessage;
const ConversationModel({
required super.id, required super.id,
required super.name, required super.typeConversation,
super.description, required super.titre,
required super.type, required super.statut,
required super.participantIds, super.dernierMessageApercu,
super.organizationId, super.dernierMessageType,
this.lastMessage, super.dernierMessageAt,
super.unreadCount, super.nonLus,
super.isMuted, super.organisationId,
super.isPinned, });
super.isArchived,
required super.createdAt,
super.updatedAt,
super.avatarUrl,
super.metadata,
}) : super(lastMessage: lastMessage);
static Message? _messageFromJson(Map<String, dynamic>? json) => factory ConversationSummaryModel.fromJson(Map<String, dynamic> json) {
json == null ? null : MessageModel.fromJson(json); return ConversationSummaryModel(
id: json['id']?.toString() ?? '',
static Map<String, dynamic>? _messageToJson(Message? message) => typeConversation: json['typeConversation']?.toString() ?? 'DIRECTE',
message == null ? null : MessageModel.fromEntity(message).toJson(); titre: json['titre']?.toString() ?? '',
statut: json['statut']?.toString() ?? 'ACTIVE',
factory ConversationModel.fromJson(Map<String, dynamic> json) => dernierMessageApercu: json['dernierMessageApercu']?.toString(),
_$ConversationModelFromJson(json); dernierMessageType: json['dernierMessageType']?.toString(),
dernierMessageAt: json['dernierMessageAt'] != null
Map<String, dynamic> toJson() => _$ConversationModelToJson(this); ? DateTime.tryParse(json['dernierMessageAt'].toString())
: null,
factory ConversationModel.fromEntity(Conversation conversation) { nonLus: _parseInt(json['nonLus']),
return ConversationModel( organisationId: json['organisationId']?.toString(),
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,
); );
} }
}
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 // GENERATED CODE - DO NOT MODIFY BY HAND
// Modèles v4 : désérialisation manuelle, code generation non utilisé.
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',
};

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

View File

@@ -1,84 +1,2 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// Modèles v4 : désérialisation manuelle, code generation non utilisé.
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',
};

View File

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

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; library conversation;
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'message.dart'; import 'message.dart';
/// Type de conversation // ── Résumé de conversation (liste) ───────────────────────────────────────────
enum ConversationType {
/// Conversation individuelle (1-1)
individual,
/// Conversation de groupe /// Résumé d'une conversation pour l'affichage en liste
group, class ConversationSummary extends Equatable {
/// Canal broadcast (lecture seule pour la plupart)
broadcast,
/// Canal d'annonces organisation
announcement,
}
/// Entité Conversation
class Conversation extends Equatable {
final String id; final String id;
final String name; final String typeConversation; // DIRECTE | ROLE_CANAL | GROUPE
final String? description; final String titre;
final ConversationType type; final String statut; // ACTIVE | ARCHIVEE
final List<String> participantIds; final String? dernierMessageApercu;
final String? organizationId; final String? dernierMessageType;
final Message? lastMessage; final DateTime? dernierMessageAt;
final int unreadCount; final int nonLus;
final bool isMuted; final String? organisationId;
final bool isPinned;
final bool isArchived;
final DateTime createdAt;
final DateTime? updatedAt;
final String? avatarUrl;
final Map<String, dynamic>? metadata;
const Conversation({ const ConversationSummary({
required this.id, required this.id,
required this.name, required this.typeConversation,
this.description, required this.titre,
required this.type, required this.statut,
required this.participantIds, this.dernierMessageApercu,
this.organizationId, this.dernierMessageType,
this.lastMessage, this.dernierMessageAt,
this.unreadCount = 0, this.nonLus = 0,
this.isMuted = false, this.organisationId,
this.isPinned = false,
this.isArchived = false,
required this.createdAt,
this.updatedAt,
this.avatarUrl,
this.metadata,
}); });
/// Vérifie si la conversation a des messages non lus bool get hasUnread => nonLus > 0;
bool get hasUnread => unreadCount > 0; bool get isDirecte => typeConversation == 'DIRECTE';
bool get isRoleCanal => typeConversation == 'ROLE_CANAL';
/// Vérifie si c'est une conversation individuelle bool get isGroupe => typeConversation == 'GROUPE';
bool get isIndividual => type == ConversationType.individual; bool get isActive => statut == 'ACTIVE';
/// 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,
);
}
@override @override
List<Object?> get props => [ List<Object?> get props => [
id, id, typeConversation, titre, statut,
name, dernierMessageApercu, dernierMessageType, dernierMessageAt,
description, nonLus, organisationId,
type, ];
participantIds, }
organizationId,
lastMessage, // ── Participant ───────────────────────────────────────────────────────────────
unreadCount,
isMuted, /// Participant dans une conversation
isPinned, class ConversationParticipant extends Equatable {
isArchived, final String membreId;
createdAt, final String? prenom;
updatedAt, final String? nom;
avatarUrl, final String? roleDansConversation;
metadata, 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; library message;
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
/// Type de message /// Message dans une conversation
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
class Message extends Equatable { class Message extends Equatable {
final String id; final String id;
final String conversationId; final String typeMessage; // TEXTE | VOCAL | IMAGE | SYSTEME
final String senderId; final String? contenu;
final String senderName; final String? urlFichier;
final String? senderAvatar; final int? dureeAudio;
final String content; final bool supprime;
final MessageType type; final String? expediteurId;
final MessageStatus status; final String? expediteurNom;
final MessagePriority priority; final String? expediteurPrenom;
final List<String> recipientIds; final String? messageParentId;
final List<String>? recipientRoles; final String? messageParentApercu;
final String? organizationId; final DateTime? dateEnvoi;
final DateTime createdAt;
final DateTime? readAt;
final Map<String, dynamic>? metadata;
final List<String>? attachments;
final bool isEdited;
final DateTime? editedAt;
final bool isDeleted;
const Message({ const Message({
required this.id, required this.id,
required this.conversationId, required this.typeMessage,
required this.senderId, this.contenu,
required this.senderName, this.urlFichier,
this.senderAvatar, this.dureeAudio,
required this.content, this.supprime = false,
required this.type, this.expediteurId,
required this.status, this.expediteurNom,
this.priority = MessagePriority.normal, this.expediteurPrenom,
required this.recipientIds, this.messageParentId,
this.recipientRoles, this.messageParentApercu,
this.organizationId, this.dateEnvoi,
required this.createdAt,
this.readAt,
this.metadata,
this.attachments,
this.isEdited = false,
this.editedAt,
this.isDeleted = false,
}); });
/// Vérifie si le message a été lu String get expediteurNomComplet {
bool get isRead => status == MessageStatus.read; 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 isTexte => typeMessage == 'TEXTE';
bool get isUrgent => priority == MessagePriority.urgent; 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 /// Texte à afficher dans la liste (aperçu)
bool get isBroadcast => type == MessageType.broadcast; String get apercu {
if (supprime) return '🚫 Message supprimé';
/// Vérifie si le message a des pièces jointes if (isVocal) return '🎙️ Note vocale${dureeAudio != null ? ' (${dureeAudio}s)' : ''}';
bool get hasAttachments => attachments != null && attachments!.isNotEmpty; if (isImage) return '📷 Image';
if (isSysteme) return contenu ?? '🔔 Notification système';
/// Copie avec modifications return contenu ?? '';
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,
);
} }
@override @override
List<Object?> get props => [ List<Object?> get props => [
id, id, typeMessage, contenu, urlFichier, dureeAudio, supprime,
conversationId, expediteurId, expediteurNom, expediteurPrenom,
senderId, messageParentId, messageParentApercu, dateEnvoi,
senderName,
senderAvatar,
content,
type,
status,
priority,
recipientIds,
recipientRoles,
organizationId,
createdAt,
readAt,
metadata,
attachments,
isEdited,
editedAt,
isDeleted,
]; ];
} }

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; library messaging_repository;
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../entities/conversation.dart'; import '../entities/conversation.dart';
import '../entities/message_template.dart'; import '../entities/message.dart';
import '../entities/contact_policy.dart';
/// Interface du repository de messagerie /// Interface du repository de messagerie
abstract class MessagingRepository { abstract class MessagingRepository {
// === CONVERSATIONS ===
/// Récupère toutes les conversations de l'utilisateur // ── Conversations ─────────────────────────────────────────────────────────
Future<Either<Failure, List<Conversation>>> getConversations({
String? organizationId, /// Récupère les conversations résumées de l'utilisateur connecté
bool includeArchived = false, 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 /// Démarre un canal de communication avec un rôle officiel
Future<Either<Failure, Conversation>> getConversationById(String conversationId); Future<Conversation> demarrerConversationRole({
required String roleCible,
/// Crée une nouvelle conversation required String organisationId,
Future<Either<Failure, Conversation>> createConversation({ String? premierMessage,
required String name,
required List<String> participantIds,
String? organizationId,
String? description,
}); });
/// Archive une conversation /// Archive une conversation
Future<Either<Failure, void>> archiveConversation(String conversationId); Future<void> archiverConversation(String conversationId);
/// Marque une conversation comme lue // ── Messages ──────────────────────────────────────────────────────────────
Future<Either<Failure, void>> markConversationAsRead(String conversationId);
/// Mute/démute une conversation /// Envoie un message dans une conversation
Future<Either<Failure, void>> toggleMuteConversation(String conversationId); Future<Message> envoyerMessage(
String conversationId, {
/// Pin/unpin une conversation required String typeMessage,
Future<Either<Failure, void>> togglePinConversation(String conversationId); String? contenu,
String? urlFichier,
// === MESSAGES === int? dureeAudio,
String? messageParentId,
/// Récupère les messages d'une conversation
Future<Either<Failure, List<Message>>> getMessages({
required String conversationId,
int? limit,
String? beforeMessageId,
}); });
/// Envoie un message individuel /// Récupère l'historique des messages (paginé)
Future<Either<Failure, Message>> sendMessage({ Future<List<Message>> getMessages(String conversationId, {int page = 0});
required String conversationId,
required String content, /// Marque tous les messages d'une conversation comme lus
List<String>? attachments, Future<void> marquerLu(String conversationId);
MessagePriority priority = MessagePriority.normal,
/// 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 /// Débloque un membre
Future<Either<Failure, Message>> sendBroadcast({ Future<void> debloquerMembre(String membreId, {String? organisationId});
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
});
/// Envoie un message ciblé par rôles /// Récupère la liste des membres bloqués
Future<Either<Failure, Message>> sendTargetedMessage({ Future<List<Map<String, dynamic>>> getMesBlocages();
required String organizationId,
required List<String> targetRoles,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
});
/// Marque un message comme lu // ── Politique de communication ────────────────────────────────────────────
Future<Either<Failure, void>> markMessageAsRead(String messageId);
/// Édite un message /// Récupère la politique de communication d'une organisation
Future<Either<Failure, Message>> editMessage({ Future<ContactPolicy> getPolitique(String organisationId);
required String messageId,
required String newContent,
});
/// Supprime un message /// Met à jour la politique de communication (ADMIN seulement)
Future<Either<Failure, void>> deleteMessage(String messageId); Future<ContactPolicy> mettreAJourPolitique(
String organisationId, {
// === TEMPLATES === required String typePolitique,
required bool autoriserMembreVersMembre,
/// Récupère tous les templates disponibles required bool autoriserMembreVersRole,
Future<Either<Failure, List<MessageTemplate>>> getTemplates({ required bool autoriserNotesVocales,
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,
}); });
} }

View File

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

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; library get_messages;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart'; import '../entities/message.dart';
import '../repositories/messaging_repository.dart'; import '../repositories/messaging_repository.dart';
@@ -13,19 +12,10 @@ class GetMessages {
GetMessages(this.repository); GetMessages(this.repository);
Future<Either<Failure, List<Message>>> call({ Future<List<Message>> call({
required String conversationId, required String conversationId,
int? limit, int page = 0,
String? beforeMessageId,
}) async { }) async {
if (conversationId.isEmpty) { return await repository.getMessages(conversationId, page: page);
return Left(ValidationFailure('ID conversation requis'));
}
return await repository.getMessages(
conversationId: conversationId,
limit: limit,
beforeMessageId: beforeMessageId,
);
} }
} }

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