feat(unionflow): ajout Spec-Kit, constitution, mission mutuelles

- Config Spec-Kit pour Spec-Driven Development
- CONSTITUTION.md + .specify/memory/constitution.md
- Commandes Cursor /speckit.*, règles projet
- Mission: associations + mutuelles d'épargne et de financement
- .gitignore: versionner config spec-kit unionflow

Made-with: Cursor
This commit is contained in:
dahoud
2026-02-27 14:41:07 +00:00
parent 144b68f8e7
commit b1957c1c81
631 changed files with 104070 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

View File

@@ -0,0 +1,39 @@
# UnionFlow Mobile
Application mobile Flutter pour la gestion des mutuelles, associations et organisations.
**Version** : 2.0
**Status** : Active
**Dernière mise à jour** : 2026-01-04
## Installation
```bash
flutter pub get
flutter pub run build_runner build --delete-conflicting-outputs
flutter run
```
## Architecture
Clean Architecture + BLoC Pattern
```
lib/
├── core/ # Utilitaires partagés
├── features/ # Modules fonctionnels
│ ├── members/
│ ├── cotisations/
│ ├── events/
│ └── organisations/
└── main.dart
```
## Technologies
- Flutter 3.x
- Dart 3.x
- flutter_bloc
- dio
- get_it

View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@@ -0,0 +1,13 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,55 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
android {
namespace = "dev.lions.unionflow_mobile_apps"
compileSdk = 35
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "dev.lions.unionflow_mobile_apps"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
// Configuration pour flutter_appauth
manifestPlaceholders = [
'appAuthRedirectScheme': 'dev.lions.unionflow-mobile',
'applicationName': 'android.app.Application'
]
}
buildTypes {
release {
// TODO: Configurer signingConfigs.release avec votre keystore de production
// signingConfig = signingConfigs.release
signingConfig = signingConfigs.debug
// Activer la minification et l'obfuscation pour la release
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,17 @@
# Flutter ProGuard Rules
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
-dontwarn io.flutter.embedding.**
# Keep annotations
-keepattributes *Annotation*
# Keep Keycloak/OAuth related classes
-keep class net.openid.appauth.** { *; }
# Keep Gson/JSON serialization
-keepattributes Signature
-keepattributes EnclosingMethod
# Keep crypto classes for PKCE
-keep class javax.crypto.** { *; }

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,74 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permissions pour les communications -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.SEND_SMS" />
<application
android:label="unionflow_mobile_apps"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config"
android:allowBackup="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<!-- Intent filter standard -->
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Intent filter pour flutter_appauth -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="${appAuthRedirectScheme}" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- Queries pour les communications -->
<intent>
<action android:name="android.intent.action.CALL"/>
</intent>
<intent>
<action android:name="android.intent.action.SENDTO"/>
<data android:scheme="sms"/>
</intent>
<intent>
<action android:name="android.intent.action.SENDTO"/>
<data android:scheme="mailto"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,29 @@
package dev.lions.unionflow_mobile_apps
import android.content.Intent
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent?) {
if (intent?.action == Intent.ACTION_VIEW) {
val data = intent.data
if (data != null && data.scheme == "dev.lions.unionflow-mobile") {
// L'intent sera automatiquement traité par flutter_appauth
android.util.Log.d("MainActivity", "Deep link reçu: $data")
}
}
}
}

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?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">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</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

@@ -0,0 +1,18 @@
<?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">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</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,17 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Production : cleartext interdit par défaut -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
</base-config>
<!-- Exceptions pour le développement local uniquement -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.1.11</domain>
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,39 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
// Configuration globale Java 17 pour tous les sous-projets (compatible avec Gradle 8.7)
subprojects {
afterEvaluate { project ->
if (project.hasProperty('android')) {
project.android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
}
// Configuration Kotlin pour tous les projets
project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
jvmTarget = '17'
}
}
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@@ -0,0 +1,15 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError -Djava.net.preferIPv4Stack=true
android.useAndroidX=true
android.enableJetifier=true
# Configuration réseau - Décommentez et adaptez si nécessaire
# systemProp.http.proxyHost=your.proxy.host
# systemProp.http.proxyPort=8080
# systemProp.https.proxyHost=your.proxy.host
# systemProp.https.proxyPort=8080
# systemProp.http.nonProxyHosts=localhost|127.0.0.1
# systemProp.https.nonProxyHosts=localhost|127.0.0.1
# Timeout augmenté pour connexions lentes
systemProp.org.gradle.internal.http.connectionTimeout=120000
systemProp.org.gradle.internal.http.socketTimeout=120000

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip

View File

@@ -0,0 +1,25 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.1.0" apply false
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
}
include ":app"

View File

@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@@ -0,0 +1,26 @@
<?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">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = dev.lions.unionflowMobileApps;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.lions.unionflowMobileApps.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.lions.unionflowMobileApps.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.lions.unionflowMobileApps.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = dev.lions.unionflowMobileApps;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = dev.lions.unionflowMobileApps;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,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">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,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">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,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">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,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">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@@ -0,0 +1,56 @@
<?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">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Unionflow Mobile Apps</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>unionflow_mobile_apps</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<!-- Permissions pour les communications -->
<key>LSApplicationQueriesSchemes</key>
<array>
<string>tel</string>
<string>sms</string>
<string>mailto</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

View File

@@ -0,0 +1,4 @@
arb-dir: lib/l10n
template-arb-file: app_fr.arb
output-localization-file: app_localizations.dart

View File

@@ -0,0 +1,72 @@
/// Configuration principale de l'application UnionFlow
///
/// Contient la configuration globale de l'app avec thème, localisation et navigation
library app;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../shared/design_system/theme/app_theme_sophisticated.dart';
import '../features/authentication/presentation/bloc/auth_bloc.dart';
import '../core/l10n/locale_provider.dart';
import 'router/app_router.dart';
/// Application principale avec système d'authentification Keycloak
class UnionFlowApp extends StatelessWidget {
final LocaleProvider localeProvider;
const UnionFlowApp({super.key, required this.localeProvider});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: localeProvider),
BlocProvider(
create: (context) => AuthBloc()..add(const AuthStatusChecked()),
),
],
child: Consumer<LocaleProvider>(
builder: (context, localeProvider, child) {
return MaterialApp(
title: 'UnionFlow',
debugShowCheckedModeBanner: false,
// Configuration du thème
theme: AppThemeSophisticated.lightTheme,
// darkTheme: AppThemeSophisticated.darkTheme,
// themeMode: ThemeMode.system,
// Configuration de la localisation
locale: localeProvider.locale,
supportedLocales: LocaleProvider.supportedLocales,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
// Configuration des routes
routes: AppRouter.routes,
// Page d'accueil par défaut
initialRoute: AppRouter.initialRoute,
// Builder global pour gérer les erreurs
builder: (context, child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: const TextScaler.linear(1.0),
),
child: child ?? const SizedBox(),
);
},
);
},
),
);
}
}

View File

@@ -0,0 +1,37 @@
/// Configuration centralisée des routes de l'application
///
/// Gère toutes les routes et la navigation de l'application UnionFlow
library app_router;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
import '../../features/authentication/presentation/pages/login_page.dart';
import '../../core/navigation/main_navigation_layout.dart';
/// Configuration des routes de l'application
class AppRouter {
/// Routes principales de l'application
static Map<String, WidgetBuilder> get routes => {
'/': (context) => BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is AuthLoading) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
} else if (state is AuthAuthenticated) {
return const MainNavigationLayout();
} else {
return const LoginPage();
}
},
),
'/dashboard': (context) => const MainNavigationLayout(),
'/login': (context) => const LoginPage(),
};
/// Route initiale de l'application
static const String initialRoute = '/';
}

View File

@@ -0,0 +1,96 @@
/// Environnements de déploiement de l'application
enum Environment { dev, staging, prod }
/// Configuration centralisée par environnement.
/// Les URLs sont injectées via --dart-define=ENV=dev|staging|prod
class AppConfig {
static late final Environment _environment;
static late final String apiBaseUrl;
static late final String keycloakBaseUrl;
static late final String wsBaseUrl;
static late final bool enableDebugMode;
static late final bool enableLogging;
static late final bool enableCrashReporting;
static late final bool enableAnalytics;
/// Initialise la configuration à partir de l'environnement.
/// Appeler dans main() avant runApp().
static void initialize() {
const envString = String.fromEnvironment('ENV', defaultValue: 'dev');
_environment = Environment.values.firstWhere(
(e) => e.name == envString,
orElse: () => Environment.dev,
);
switch (_environment) {
case Environment.dev:
apiBaseUrl = const String.fromEnvironment(
'API_URL',
defaultValue: 'http://192.168.1.11:8085',
);
keycloakBaseUrl = const String.fromEnvironment(
'KEYCLOAK_URL',
defaultValue: 'http://192.168.1.11:8180',
);
wsBaseUrl = const String.fromEnvironment(
'WS_URL',
defaultValue: 'ws://192.168.1.11:8085',
);
enableDebugMode = true;
enableLogging = true;
enableCrashReporting = false;
enableAnalytics = false;
case Environment.staging:
apiBaseUrl = const String.fromEnvironment(
'API_URL',
defaultValue: 'https://api-staging.lions.dev',
);
keycloakBaseUrl = const String.fromEnvironment(
'KEYCLOAK_URL',
defaultValue: 'https://security-staging.lions.dev',
);
wsBaseUrl = const String.fromEnvironment(
'WS_URL',
defaultValue: 'wss://api-staging.lions.dev',
);
enableDebugMode = false;
enableLogging = true;
enableCrashReporting = true;
enableAnalytics = false;
case Environment.prod:
apiBaseUrl = const String.fromEnvironment(
'API_URL',
defaultValue: 'https://api.lions.dev',
);
keycloakBaseUrl = const String.fromEnvironment(
'KEYCLOAK_URL',
defaultValue: 'https://security.lions.dev',
);
wsBaseUrl = const String.fromEnvironment(
'WS_URL',
defaultValue: 'wss://api.lions.dev',
);
enableDebugMode = false;
enableLogging = false;
enableCrashReporting = true;
enableAnalytics = true;
}
}
static Environment get environment => _environment;
static bool get isDev => _environment == Environment.dev;
static bool get isStaging => _environment == Environment.staging;
static bool get isProd => _environment == Environment.prod;
/// URL complète du realm Keycloak
static String get keycloakRealmUrl => '$keycloakBaseUrl/realms/unionflow';
/// URL du endpoint token Keycloak
static String get keycloakTokenUrl =>
'$keycloakRealmUrl/protocol/openid-connect/token';
/// URL du endpoint WebSocket dashboard
static String get wsDashboardUrl => '$wsBaseUrl/ws/dashboard';
}

View File

@@ -0,0 +1,299 @@
/// Constantes globales de l'application
library app_constants;
/// Constantes de l'application UnionFlow
class AppConstants {
// Empêcher l'instanciation
AppConstants._();
// ============================================================================
// API & BACKEND
// Les URLs sont gérées par AppConfig (lib/core/config/environment.dart).
// Utiliser AppConfig.apiBaseUrl, AppConfig.keycloakBaseUrl, etc.
// ============================================================================
/// Realm Keycloak
static const String keycloakRealm = 'unionflow';
/// Client ID Keycloak
static const String keycloakClientId = 'unionflow-mobile';
/// Redirect URI pour l'authentification
static const String redirectUri = 'dev.lions.unionflow-mobile://auth/callback';
// ============================================================================
// PAGINATION
// ============================================================================
/// Taille de page par défaut pour les listes paginées
static const int defaultPageSize = 20;
/// Taille de page maximale
static const int maxPageSize = 100;
/// Taille de page minimale
static const int minPageSize = 5;
/// Page initiale
static const int initialPage = 0;
// ============================================================================
// TIMEOUTS
// ============================================================================
/// Timeout de connexion (en secondes)
static const Duration connectTimeout = Duration(seconds: 30);
/// Timeout d'envoi (en secondes)
static const Duration sendTimeout = Duration(seconds: 30);
/// Timeout de réception (en secondes)
static const Duration receiveTimeout = Duration(seconds: 30);
// ============================================================================
// CACHE
// ============================================================================
/// Durée d'expiration du cache (en heures)
static const Duration cacheExpiration = Duration(hours: 1);
/// Durée d'expiration du cache pour les données statiques (en jours)
static const Duration staticCacheExpiration = Duration(days: 7);
/// Taille maximale du cache (en MB)
static const int maxCacheSize = 100;
// ============================================================================
// UI & DESIGN
// ============================================================================
/// Padding par défaut
static const double defaultPadding = 16.0;
/// Padding petit
static const double smallPadding = 8.0;
/// Padding large
static const double largePadding = 24.0;
/// Padding extra large
static const double extraLargePadding = 32.0;
/// Rayon de bordure par défaut
static const double defaultRadius = 8.0;
/// Rayon de bordure petit
static const double smallRadius = 4.0;
/// Rayon de bordure large
static const double largeRadius = 12.0;
/// Rayon de bordure extra large
static const double extraLargeRadius = 16.0;
/// Hauteur de l'AppBar
static const double appBarHeight = 56.0;
/// Hauteur du BottomNavigationBar
static const double bottomNavBarHeight = 60.0;
/// Largeur maximale pour les écrans larges (tablettes, desktop)
static const double maxContentWidth = 1200.0;
// ============================================================================
// ANIMATIONS
// ============================================================================
/// Durée d'animation par défaut
static const Duration defaultAnimationDuration = Duration(milliseconds: 300);
/// Durée d'animation rapide
static const Duration fastAnimationDuration = Duration(milliseconds: 150);
/// Durée d'animation lente
static const Duration slowAnimationDuration = Duration(milliseconds: 500);
// ============================================================================
// VALIDATION
// ============================================================================
/// Longueur minimale du mot de passe
static const int minPasswordLength = 8;
/// Longueur maximale du mot de passe
static const int maxPasswordLength = 128;
/// Longueur minimale du nom
static const int minNameLength = 2;
/// Longueur maximale du nom
static const int maxNameLength = 100;
/// Longueur maximale de la description
static const int maxDescriptionLength = 1000;
/// Longueur maximale du titre
static const int maxTitleLength = 200;
// ============================================================================
// FORMATS
// ============================================================================
/// Format de date par défaut (dd/MM/yyyy)
static const String defaultDateFormat = 'dd/MM/yyyy';
/// Format de date et heure (dd/MM/yyyy HH:mm)
static const String defaultDateTimeFormat = 'dd/MM/yyyy HH:mm';
/// Format de date court (dd/MM/yy)
static const String shortDateFormat = 'dd/MM/yy';
/// Format de date long (EEEE dd MMMM yyyy)
static const String longDateFormat = 'EEEE dd MMMM yyyy';
/// Format d'heure (HH:mm)
static const String timeFormat = 'HH:mm';
/// Format d'heure avec secondes (HH:mm:ss)
static const String timeWithSecondsFormat = 'HH:mm:ss';
// ============================================================================
// DEVISE
// ============================================================================
/// Devise par défaut
static const String defaultCurrency = 'EUR';
/// Symbole de la devise par défaut
static const String defaultCurrencySymbol = '';
// ============================================================================
// IMAGES
// ============================================================================
/// Taille maximale d'upload d'image (en MB)
static const int maxImageUploadSize = 5;
/// Formats d'image acceptés
static const List<String> acceptedImageFormats = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
/// Qualité de compression d'image (0-100)
static const int imageCompressionQuality = 85;
// ============================================================================
// DOCUMENTS
// ============================================================================
/// Taille maximale d'upload de document (en MB)
static const int maxDocumentUploadSize = 10;
/// Formats de document acceptés
static const List<String> acceptedDocumentFormats = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt'];
// ============================================================================
// NOTIFICATIONS
// ============================================================================
/// Durée d'affichage des snackbars (en secondes)
static const Duration snackbarDuration = Duration(seconds: 3);
/// Durée d'affichage des snackbars d'erreur (en secondes)
static const Duration errorSnackbarDuration = Duration(seconds: 5);
/// Durée d'affichage des snackbars de succès (en secondes)
static const Duration successSnackbarDuration = Duration(seconds: 2);
// ============================================================================
// RECHERCHE
// ============================================================================
/// Délai de debounce pour la recherche (en millisecondes)
static const Duration searchDebounce = Duration(milliseconds: 500);
/// Nombre minimum de caractères pour déclencher une recherche
static const int minSearchLength = 2;
// ============================================================================
// REFRESH
// ============================================================================
/// Intervalle de rafraîchissement automatique (en minutes)
static const Duration autoRefreshInterval = Duration(minutes: 5);
// ============================================================================
// STORAGE KEYS
// ============================================================================
/// Clé pour le token d'accès
static const String accessTokenKey = 'access_token';
/// Clé pour le refresh token
static const String refreshTokenKey = 'refresh_token';
/// Clé pour l'ID token
static const String idTokenKey = 'id_token';
/// Clé pour les données utilisateur
static const String userDataKey = 'user_data';
/// Clé pour les préférences de thème
static const String themePreferenceKey = 'theme_preference';
/// Clé pour les préférences de langue
static const String languagePreferenceKey = 'language_preference';
/// Clé pour le mode hors ligne
static const String offlineModeKey = 'offline_mode';
// ============================================================================
// REGEX PATTERNS
// ============================================================================
/// Pattern pour valider un email
static const String emailPattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$';
/// Pattern pour valider un numéro de téléphone français
static const String phonePattern = r'^(?:(?:\+|00)33|0)\s*[1-9](?:[\s.-]*\d{2}){4}$';
/// Pattern pour valider un code postal français
static const String postalCodePattern = r'^\d{5}$';
/// Pattern pour valider une URL
static const String urlPattern = r'^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$';
// ============================================================================
// FEATURES FLAGS
// ============================================================================
// Feature flags pilotés par AppConfig (lib/core/config/environment.dart).
// Utiliser AppConfig.enableDebugMode, AppConfig.enableLogging, etc.
/// Activer le mode offline
static const bool enableOfflineMode = false;
// ============================================================================
// APP INFO
// ============================================================================
/// Nom de l'application
static const String appName = 'UnionFlow';
/// Version de l'application
static const String appVersion = '1.0.0';
/// Build number
static const String buildNumber = '1';
/// Email de support
static const String supportEmail = 'support@unionflow.com';
/// URL du site web
static const String websiteUrl = 'https://unionflow.com';
/// URL des conditions d'utilisation
static const String termsOfServiceUrl = 'https://unionflow.com/terms';
/// URL de la politique de confidentialité
static const String privacyPolicyUrl = 'https://unionflow.com/privacy';
}

View File

@@ -0,0 +1,120 @@
/// Configuration globale de l'injection de dépendances
library app_di;
import 'package:dio/dio.dart';
import 'package:get_it/get_it.dart';
import '../network/dio_client.dart';
import '../network/network_info.dart';
import '../../features/organizations/di/organizations_di.dart';
import '../../features/members/di/membres_di.dart';
import '../../features/events/di/evenements_di.dart';
import '../../features/contributions/di/contributions_di.dart';
import '../../features/adhesions/di/adhesions_di.dart';
import '../../features/solidarity/di/solidarity_di.dart';
import '../../features/admin/di/admin_di.dart';
import '../../features/dashboard/di/dashboard_di.dart';
import '../../features/profile/di/profile_di.dart';
import '../../features/notifications/di/notifications_di.dart';
import '../../features/reports/di/reports_di.dart';
/// Gestionnaire global des dépendances
class AppDI {
static final GetIt _getIt = GetIt.instance;
/// Initialise toutes les dépendances de l'application
static Future<void> initialize() async {
// Configuration du client HTTP
await _setupNetworking();
// Configuration des modules
await _setupModules();
}
/// Configure les services réseau
static Future<void> _setupNetworking() async {
// Client Dio
final dioClient = DioClient();
_getIt.registerSingleton<DioClient>(dioClient);
_getIt.registerSingleton<Dio>(dioClient.dio);
// Network Info (pour l'instant, on simule toujours connecté)
_getIt.registerLazySingleton<NetworkInfo>(
() => _MockNetworkInfo(),
);
}
/// Configure tous les modules de l'application
static Future<void> _setupModules() async {
// Module Organizations
OrganizationsDI.registerDependencies();
// Module Membres
MembresDI.register();
// Module Événements
EvenementsDI.register();
// Module Contributions
registerCotisationsDependencies(_getIt);
// Module Adhésions
registerAdhesionsDependencies(_getIt);
// Module Solidarité (demandes d'aide)
registerSolidarityDependencies(_getIt);
// Module Admin (gestion utilisateurs SUPER_ADMIN)
registerAdminDependencies(_getIt);
// Module Dashboard
DashboardDI.registerDependencies();
// Module Profil utilisateur
ProfileDI.register();
// Module Notifications
NotificationsDI.register();
// Module Rapports & Analytics
ReportsDI.register();
}
/// Nettoie toutes les dépendances
static Future<void> dispose() async {
// Nettoyer les modules
OrganizationsDI.unregisterDependencies();
MembresDI.unregister();
EvenementsDI.unregister();
// Nettoyer les services globaux
if (_getIt.isRegistered<Dio>()) {
_getIt.unregister<Dio>();
}
if (_getIt.isRegistered<DioClient>()) {
_getIt.unregister<DioClient>();
}
// Reset complet
await _getIt.reset();
}
/// Obtient l'instance GetIt
static GetIt get instance => _getIt;
/// Obtient le client Dio
static Dio get dio => _getIt<Dio>();
/// Obtient le client Dio wrapper
static DioClient get dioClient => _getIt<DioClient>();
/// Nettoie toutes les dépendances
static Future<void> cleanup() async {
await _getIt.reset();
}
}
/// Mock de NetworkInfo pour les tests et développement
class _MockNetworkInfo implements NetworkInfo {
@override
Future<bool> get isConnected async => true;
}

View File

@@ -0,0 +1,15 @@
import 'package:get_it/get_it.dart';
import 'app_di.dart';
/// Service locator global - alias pour faciliter l'utilisation
final GetIt sl = AppDI.instance;
/// Initialise toutes les dépendances de l'application
Future<void> initializeDependencies() async {
await AppDI.initialize();
}
/// Nettoie toutes les dépendances
Future<void> cleanupDependencies() async {
await AppDI.cleanup();
}

View File

@@ -0,0 +1,192 @@
/// Gestionnaire d'erreurs global pour l'application
library error_handler;
import 'package:dio/dio.dart';
/// Classe utilitaire pour gérer les erreurs de manière centralisée
class ErrorHandler {
/// Convertit une erreur en message utilisateur lisible
static String getErrorMessage(dynamic error) {
if (error is DioException) {
return _handleDioError(error);
} else if (error is String) {
return error;
} else if (error is Exception) {
return error.toString().replaceAll('Exception: ', '');
}
return 'Une erreur inattendue s\'est produite.';
}
/// Gère les erreurs Dio spécifiques
static String _handleDioError(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
return 'Délai de connexion dépassé.\nVérifiez votre connexion internet.';
case DioExceptionType.sendTimeout:
return 'Délai d\'envoi dépassé.\nVérifiez votre connexion internet.';
case DioExceptionType.receiveTimeout:
return 'Délai de réception dépassé.\nLe serveur met trop de temps à répondre.';
case DioExceptionType.badResponse:
return _handleBadResponse(error.response);
case DioExceptionType.cancel:
return 'Requête annulée.';
case DioExceptionType.connectionError:
return 'Erreur de connexion.\nVérifiez votre connexion internet.';
case DioExceptionType.badCertificate:
return 'Erreur de certificat SSL.\nLa connexion n\'est pas sécurisée.';
case DioExceptionType.unknown:
default:
if (error.message?.contains('SocketException') ?? false) {
return 'Impossible de se connecter au serveur.\nVérifiez votre connexion internet.';
}
return 'Erreur de connexion.\nVeuillez réessayer.';
}
}
/// Gère les réponses HTTP avec erreur
static String _handleBadResponse(Response? response) {
if (response == null) {
return 'Erreur serveur inconnue.';
}
// Essayer d'extraire le message d'erreur du body
String? errorMessage;
if (response.data is Map) {
errorMessage = response.data['message'] ??
response.data['error'] ??
response.data['details'];
}
switch (response.statusCode) {
case 400:
return errorMessage ?? 'Requête invalide.\nVérifiez les données saisies.';
case 401:
return errorMessage ?? 'Non authentifié.\nVeuillez vous reconnecter.';
case 403:
return errorMessage ?? 'Accès refusé.\nVous n\'avez pas les permissions nécessaires.';
case 404:
return errorMessage ?? 'Ressource non trouvée.';
case 409:
return errorMessage ?? 'Conflit.\nCette ressource existe déjà.';
case 422:
return errorMessage ?? 'Données invalides.\nVérifiez les informations saisies.';
case 429:
return 'Trop de requêtes.\nVeuillez patienter quelques instants.';
case 500:
return errorMessage ?? 'Erreur serveur interne.\nVeuillez réessayer plus tard.';
case 502:
return 'Passerelle incorrecte.\nLe serveur est temporairement indisponible.';
case 503:
return 'Service temporairement indisponible.\nVeuillez réessayer plus tard.';
case 504:
return 'Délai d\'attente de la passerelle dépassé.\nLe serveur met trop de temps à répondre.';
default:
return errorMessage ?? 'Erreur serveur (${response.statusCode}).\nVeuillez réessayer.';
}
}
/// Détermine si l'erreur est une erreur réseau
static bool isNetworkError(dynamic error) {
if (error is DioException) {
return error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.connectionError ||
(error.message?.contains('SocketException') ?? false);
}
return false;
}
/// Détermine si l'erreur est une erreur d'authentification
static bool isAuthError(dynamic error) {
if (error is DioException && error.response != null) {
return error.response!.statusCode == 401;
}
return false;
}
/// Détermine si l'erreur est une erreur de permissions
static bool isPermissionError(dynamic error) {
if (error is DioException && error.response != null) {
return error.response!.statusCode == 403;
}
return false;
}
/// Détermine si l'erreur est une erreur de validation
static bool isValidationError(dynamic error) {
if (error is DioException && error.response != null) {
return error.response!.statusCode == 400 ||
error.response!.statusCode == 422;
}
return false;
}
/// Détermine si l'erreur est une erreur serveur
static bool isServerError(dynamic error) {
if (error is DioException && error.response != null) {
final statusCode = error.response!.statusCode ?? 0;
return statusCode >= 500 && statusCode < 600;
}
return false;
}
/// Extrait les détails de validation d'une erreur
static Map<String, dynamic>? getValidationErrors(dynamic error) {
if (error is DioException &&
error.response != null &&
error.response!.data is Map) {
final data = error.response!.data as Map;
if (data.containsKey('errors')) {
return data['errors'] as Map<String, dynamic>?;
}
if (data.containsKey('validationErrors')) {
return data['validationErrors'] as Map<String, dynamic>?;
}
}
return null;
}
}
/// Extension pour faciliter l'utilisation de ErrorHandler
extension ErrorHandlerExtension on Object {
/// Convertit l'objet en message d'erreur lisible
String toErrorMessage() => ErrorHandler.getErrorMessage(this);
/// Vérifie si c'est une erreur réseau
bool get isNetworkError => ErrorHandler.isNetworkError(this);
/// Vérifie si c'est une erreur d'authentification
bool get isAuthError => ErrorHandler.isAuthError(this);
/// Vérifie si c'est une erreur de permissions
bool get isPermissionError => ErrorHandler.isPermissionError(this);
/// Vérifie si c'est une erreur de validation
bool get isValidationError => ErrorHandler.isValidationError(this);
/// Vérifie si c'est une erreur serveur
bool get isServerError => ErrorHandler.isServerError(this);
/// Récupère les erreurs de validation
Map<String, dynamic>? get validationErrors => ErrorHandler.getValidationErrors(this);
}

View File

@@ -0,0 +1,50 @@
/// Exception de base pour l'application
abstract class AppException implements Exception {
final String message;
final String? code;
const AppException(this.message, [this.code]);
@override
String toString() => 'AppException: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Exception serveur
class ServerException extends AppException {
const ServerException(super.message, [super.code]);
@override
String toString() => 'ServerException: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Exception de cache
class CacheException extends AppException {
const CacheException(super.message, [super.code]);
@override
String toString() => 'CacheException: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Exception de réseau
class NetworkException extends AppException {
const NetworkException(super.message, [super.code]);
@override
String toString() => 'NetworkException: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Exception d'authentification
class AuthException extends AppException {
const AuthException(super.message, [super.code]);
@override
String toString() => 'AuthException: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Exception de validation
class ValidationException extends AppException {
const ValidationException(super.message, [super.code]);
@override
String toString() => 'ValidationException: $message${code != null ? ' (Code: $code)' : ''}';
}

View File

@@ -0,0 +1,71 @@
import 'package:equatable/equatable.dart';
/// Classe de base pour tous les échecs
abstract class Failure extends Equatable {
final String message;
final String? code;
const Failure(this.message, [this.code]);
@override
List<Object?> get props => [message, code];
@override
String toString() => 'Failure: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Échec serveur
class ServerFailure extends Failure {
const ServerFailure(super.message, [super.code]);
@override
String toString() => 'ServerFailure: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Échec de cache
class CacheFailure extends Failure {
const CacheFailure(super.message, [super.code]);
@override
String toString() => 'CacheFailure: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Échec de réseau
class NetworkFailure extends Failure {
const NetworkFailure(super.message, [super.code]);
@override
String toString() => 'NetworkFailure: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Échec d'authentification
class AuthFailure extends Failure {
const AuthFailure(super.message, [super.code]);
@override
String toString() => 'AuthFailure: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Échec de validation
class ValidationFailure extends Failure {
const ValidationFailure(super.message, [super.code]);
@override
String toString() => 'ValidationFailure: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Échec de permission
class PermissionFailure extends Failure {
const PermissionFailure(super.message, [super.code]);
@override
String toString() => 'PermissionFailure: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Échec de données non trouvées
class NotFoundFailure extends Failure {
const NotFoundFailure(super.message, [super.code]);
@override
String toString() => 'NotFoundFailure: $message${code != null ? ' (Code: $code)' : ''}';
}

View File

@@ -0,0 +1,102 @@
/// Provider pour gérer la locale de l'application
library locale_provider;
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/logger.dart';
/// Provider pour la gestion de la locale
class LocaleProvider extends ChangeNotifier {
static const String _localeKey = 'app_locale';
Locale _locale = const Locale('fr');
/// Locale actuelle
Locale get locale => _locale;
/// Locales supportées
static const List<Locale> supportedLocales = [
Locale('fr'),
Locale('en'),
];
/// Initialiser la locale depuis les préférences
Future<void> initialize() async {
try {
final prefs = await SharedPreferences.getInstance();
final localeCode = prefs.getString(_localeKey);
if (localeCode != null) {
_locale = Locale(localeCode);
AppLogger.info('Locale chargée: $localeCode');
} else {
AppLogger.info('Locale par défaut: fr');
}
notifyListeners();
} catch (e, stackTrace) {
AppLogger.error(
'Erreur lors du chargement de la locale',
error: e,
stackTrace: stackTrace,
);
}
}
/// Changer la locale
Future<void> setLocale(Locale locale) async {
if (!supportedLocales.contains(locale)) {
AppLogger.warning('Locale non supportée: ${locale.languageCode}');
return;
}
if (_locale == locale) {
return;
}
try {
_locale = locale;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_localeKey, locale.languageCode);
AppLogger.info('Locale changée: ${locale.languageCode}');
AppLogger.userAction('Change language', data: {'locale': locale.languageCode});
} catch (e, stackTrace) {
AppLogger.error(
'Erreur lors du changement de locale',
error: e,
stackTrace: stackTrace,
);
}
}
/// Basculer entre FR et EN
Future<void> toggleLocale() async {
final newLocale = _locale.languageCode == 'fr'
? const Locale('en')
: const Locale('fr');
await setLocale(newLocale);
}
/// Obtenir le nom de la langue actuelle
String get currentLanguageName {
switch (_locale.languageCode) {
case 'fr':
return 'Français';
case 'en':
return 'English';
default:
return 'Français';
}
}
/// Vérifier si la locale est française
bool get isFrench => _locale.languageCode == 'fr';
/// Vérifier si la locale est anglaise
bool get isEnglish => _locale.languageCode == 'en';
}

View File

@@ -0,0 +1,561 @@
/// Système de navigation adaptatif basé sur les rôles
/// Navigation qui s'adapte selon les permissions et rôles utilisateurs
library adaptive_navigation;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
import '../../features/authentication/data/models/user_role.dart';
import '../../features/authentication/data/models/permission_matrix.dart';
import '../../shared/widgets/adaptive_widget.dart';
/// Élément de navigation adaptatif
class AdaptiveNavigationItem {
/// Icône de l'élément
final IconData icon;
/// Icône sélectionnée (optionnelle)
final IconData? selectedIcon;
/// Libellé de l'élément
final String label;
/// Route de destination
final String route;
/// Permissions requises pour afficher cet élément
final List<String> requiredPermissions;
/// Rôles minimum requis
final UserRole? minimumRole;
/// Badge de notification (optionnel)
final String? badge;
/// Couleur personnalisée (optionnelle)
final Color? color;
const AdaptiveNavigationItem({
required this.icon,
this.selectedIcon,
required this.label,
required this.route,
this.requiredPermissions = const [],
this.minimumRole,
this.badge,
this.color,
});
}
/// Drawer de navigation adaptatif
class AdaptiveNavigationDrawer extends StatelessWidget {
/// Callback de navigation
final Function(String route) onNavigate;
/// Callback de déconnexion
final VoidCallback onLogout;
/// Éléments de navigation personnalisés
final List<AdaptiveNavigationItem>? customItems;
const AdaptiveNavigationDrawer({
super.key,
required this.onNavigate,
required this.onLogout,
this.customItems,
});
@override
Widget build(BuildContext context) {
return AdaptiveWidget(
roleWidgets: {
UserRole.superAdmin: () => _buildSuperAdminDrawer(context),
UserRole.orgAdmin: () => _buildOrgAdminDrawer(context),
UserRole.moderator: () => _buildModeratorDrawer(context),
UserRole.activeMember: () => _buildActiveMemberDrawer(context),
UserRole.simpleMember: () => _buildSimpleMemberDrawer(context),
UserRole.visitor: () => _buildVisitorDrawer(context),
},
fallbackWidget: _buildBasicDrawer(context),
loadingWidget: _buildLoadingDrawer(context),
);
}
/// Drawer pour Super Admin
Widget _buildSuperAdminDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Command Center',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.SYSTEM_ADMIN],
),
const AdaptiveNavigationItem(
icon: Icons.business,
label: 'Organisations',
route: '/organizations',
requiredPermissions: [PermissionMatrix.ORG_CREATE],
),
const AdaptiveNavigationItem(
icon: Icons.people,
label: 'Utilisateurs Globaux',
route: '/global-users',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.settings,
label: 'Administration',
route: '/system-admin',
requiredPermissions: [PermissionMatrix.SYSTEM_CONFIG],
),
const AdaptiveNavigationItem(
icon: Icons.analytics,
label: 'Analytics',
route: '/analytics',
requiredPermissions: [PermissionMatrix.DASHBOARD_ANALYTICS],
),
const AdaptiveNavigationItem(
icon: Icons.security,
label: 'Sécurité',
route: '/security',
requiredPermissions: [PermissionMatrix.SYSTEM_SECURITY],
),
];
return _buildDrawer(
context,
'Super Administrateur',
const Color(0xFF6C5CE7),
Icons.admin_panel_settings,
items,
);
}
/// Drawer pour Org Admin
Widget _buildOrgAdminDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Control Panel',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
const AdaptiveNavigationItem(
icon: Icons.people,
label: 'Membres',
route: '/members',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.account_balance_wallet,
label: 'Finances',
route: '/finances',
requiredPermissions: [PermissionMatrix.FINANCES_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements',
route: '/events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.volunteer_activism,
label: 'Solidarité',
route: '/solidarity',
requiredPermissions: [PermissionMatrix.SOLIDARITY_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.assessment,
label: 'Rapports',
route: '/reports',
requiredPermissions: [PermissionMatrix.REPORTS_GENERATE],
),
const AdaptiveNavigationItem(
icon: Icons.settings,
label: 'Configuration',
route: '/org-settings',
requiredPermissions: [PermissionMatrix.ORG_CONFIG],
),
];
return _buildDrawer(
context,
'Administrateur',
const Color(0xFF0984E3),
Icons.business_center,
items,
);
}
/// Drawer pour Modérateur
Widget _buildModeratorDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Management Hub',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
const AdaptiveNavigationItem(
icon: Icons.gavel,
label: 'Modération',
route: '/moderation',
requiredPermissions: [PermissionMatrix.MODERATION_CONTENT],
),
const AdaptiveNavigationItem(
icon: Icons.people,
label: 'Membres',
route: '/members',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements',
route: '/events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.message,
label: 'Communication',
route: '/communication',
requiredPermissions: [PermissionMatrix.COMM_MODERATE],
),
];
return _buildDrawer(
context,
'Modérateur',
const Color(0xFFE17055),
Icons.manage_accounts,
items,
);
}
/// Drawer pour Membre Actif
Widget _buildActiveMemberDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Activity Center',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
const AdaptiveNavigationItem(
icon: Icons.person,
label: 'Mon Profil',
route: '/profile',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_OWN],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements',
route: '/events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.volunteer_activism,
label: 'Solidarité',
route: '/solidarity',
requiredPermissions: [PermissionMatrix.SOLIDARITY_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.payment,
label: 'Mes Cotisations',
route: '/my-finances',
requiredPermissions: [PermissionMatrix.FINANCES_VIEW_OWN],
),
const AdaptiveNavigationItem(
icon: Icons.message,
label: 'Messages',
route: '/messages',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
];
return _buildDrawer(
context,
'Membre Actif',
const Color(0xFF00B894),
Icons.groups,
items,
);
}
/// Drawer pour Membre Simple
Widget _buildSimpleMemberDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Mon Espace',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
const AdaptiveNavigationItem(
icon: Icons.person,
label: 'Mon Profil',
route: '/profile',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_OWN],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements',
route: '/events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_PUBLIC],
),
const AdaptiveNavigationItem(
icon: Icons.payment,
label: 'Mes Cotisations',
route: '/my-finances',
requiredPermissions: [PermissionMatrix.FINANCES_VIEW_OWN],
),
const AdaptiveNavigationItem(
icon: Icons.help,
label: 'Aide',
route: '/help',
requiredPermissions: [],
),
];
return _buildDrawer(
context,
'Membre',
const Color(0xFF00CEC9),
Icons.person,
items,
);
}
/// Drawer pour Visiteur
Widget _buildVisitorDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.home,
label: 'Accueil',
route: '/dashboard',
requiredPermissions: [],
),
const AdaptiveNavigationItem(
icon: Icons.info,
label: 'À Propos',
route: '/about',
requiredPermissions: [],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements Publics',
route: '/public-events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_PUBLIC],
),
const AdaptiveNavigationItem(
icon: Icons.contact_mail,
label: 'Contact',
route: '/contact',
requiredPermissions: [],
),
const AdaptiveNavigationItem(
icon: Icons.login,
label: 'Se Connecter',
route: '/login',
requiredPermissions: [],
),
];
return _buildDrawer(
context,
'Visiteur',
const Color(0xFF6C5CE7),
Icons.waving_hand,
items,
);
}
/// Drawer basique de fallback
Widget _buildBasicDrawer(BuildContext context) {
return _buildDrawer(
context,
'UnionFlow',
Colors.grey,
Icons.dashboard,
[
const AdaptiveNavigationItem(
icon: Icons.home,
label: 'Accueil',
route: '/dashboard',
),
],
);
}
/// Drawer de chargement
Widget _buildLoadingDrawer(BuildContext context) {
return Drawer(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF6C5CE7), Color(0xFF5A4FCF)],
),
),
child: const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
);
}
/// Construit un drawer avec les éléments spécifiés
Widget _buildDrawer(
BuildContext context,
String title,
Color color,
IconData icon,
List<AdaptiveNavigationItem> items,
) {
return Drawer(
child: Column(
children: [
// En-tête du drawer
_buildDrawerHeader(context, title, color, icon),
// Éléments de navigation
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
...items.map((item) => _buildNavigationItem(context, item)),
const Divider(),
_buildLogoutItem(context),
],
),
),
],
),
);
}
/// Construit l'en-tête du drawer
Widget _buildDrawerHeader(
BuildContext context,
String title,
Color color,
IconData icon,
) {
return DrawerHeader(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [color, color.withOpacity(0.8)],
),
),
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is AuthAuthenticated) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: Colors.white, size: 32),
const SizedBox(width: 12),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
Text(
state.user.fullName,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
),
Text(
state.user.email,
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 14,
),
),
],
);
}
return Row(
children: [
Icon(icon, color: Colors.white, size: 32),
const SizedBox(width: 12),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
);
},
),
);
}
/// Construit un élément de navigation
Widget _buildNavigationItem(
BuildContext context,
AdaptiveNavigationItem item,
) {
return SecureWidget(
requiredPermissions: item.requiredPermissions,
child: ListTile(
leading: Icon(item.icon, color: item.color),
title: Text(item.label),
trailing: item.badge != null
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: Text(
item.badge!,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
)
: null,
onTap: () {
Navigator.of(context).pop();
onNavigate(item.route);
},
),
);
}
/// Construit l'élément de déconnexion
Widget _buildLogoutItem(BuildContext context) {
return ListTile(
leading: const Icon(Icons.logout, color: Colors.red),
title: const Text(
'Déconnexion',
style: TextStyle(color: Colors.red),
),
onTap: () {
Navigator.of(context).pop();
onLogout();
},
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:go_router/go_router.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
import '../../features/authentication/presentation/pages/login_page.dart';
import 'main_navigation_layout.dart';
/// Configuration du routeur principal de l'application
class AppRouter {
static final GoRouter router = GoRouter(
initialLocation: '/',
redirect: (context, state) {
final authState = context.read<AuthBloc>().state;
final isAuthenticated = authState is AuthAuthenticated;
final isOnLoginPage = state.matchedLocation == '/login';
// Si pas authentifié et pas sur la page de login, rediriger vers login
if (!isAuthenticated && !isOnLoginPage) {
return '/login';
}
// Si authentifié et sur la page de login, rediriger vers dashboard
if (isAuthenticated && isOnLoginPage) {
return '/';
}
return null; // Pas de redirection
},
routes: [
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/',
name: 'main',
builder: (context, state) => const MainNavigationLayout(),
),
],
);
}

View File

@@ -0,0 +1,537 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
import '../../features/authentication/data/models/user_role.dart';
import '../../shared/design_system/unionflow_design_system.dart';
import '../../features/dashboard/presentation/pages/role_dashboards/role_dashboards.dart';
import '../../features/members/presentation/pages/members_page_wrapper.dart';
import '../../features/events/presentation/pages/events_page_wrapper.dart';
import '../../features/contributions/presentation/pages/contributions_page_wrapper.dart';
import '../../features/adhesions/presentation/pages/adhesions_page_wrapper.dart';
import '../../features/solidarity/presentation/pages/demandes_aide_page_wrapper.dart';
import '../../features/admin/presentation/pages/user_management_page.dart';
import '../../features/about/presentation/pages/about_page.dart';
import '../../features/help/presentation/pages/help_support_page.dart';
import '../../features/notifications/presentation/pages/notifications_page_wrapper.dart';
import '../../features/profile/presentation/pages/profile_page_wrapper.dart';
import '../../features/settings/presentation/pages/system_settings_page.dart';
import '../../features/backup/presentation/pages/backup_page.dart';
import '../../features/logs/presentation/pages/logs_page.dart';
import '../../features/reports/presentation/pages/reports_page_wrapper.dart';
/// Layout principal avec navigation hybride
/// Bottom Navigation pour les sections principales + Drawer pour fonctions avancées
class MainNavigationLayout extends StatefulWidget {
const MainNavigationLayout({super.key});
@override
State<MainNavigationLayout> createState() => _MainNavigationLayoutState();
}
class _MainNavigationLayoutState extends State<MainNavigationLayout> {
int _selectedIndex = 0;
/// Obtient le dashboard approprié selon le rôle de l'utilisateur
Widget _getDashboardForRole(UserRole role) {
switch (role) {
case UserRole.superAdmin:
return const SuperAdminDashboard();
case UserRole.orgAdmin:
return const OrgAdminDashboard();
case UserRole.moderator:
return const ModeratorDashboard();
case UserRole.activeMember:
return const ActiveMemberDashboard();
case UserRole.simpleMember:
return const SimpleMemberDashboard();
case UserRole.visitor:
return const VisitorDashboard();
}
}
List<Widget> _getPages(UserRole role) {
return [
_getDashboardForRole(role),
const MembersPageWrapper(), // Wrapper BLoC pour connexion API
const EventsPageWrapper(), // Wrapper BLoC pour connexion API
const MorePage(), // Page "Plus" qui affiche les options avancées
];
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is! AuthAuthenticated) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
backgroundColor: ColorTokens.background,
body: SafeArea(
top: true, // Respecte le StatusBar
bottom: false, // Le BottomNavigationBar gère son propre SafeArea
child: IndexedStack(
index: _selectedIndex,
children: _getPages(state.effectiveRole),
),
),
bottomNavigationBar: SafeArea(
top: false,
child: Container(
decoration: const BoxDecoration(
color: ColorTokens.surface,
boxShadow: [
BoxShadow(
color: ColorTokens.shadow,
blurRadius: 8,
offset: Offset(0, -2),
),
],
),
child: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
currentIndex: _selectedIndex,
onTap: (index) {
setState(() {
_selectedIndex = index;
});
},
backgroundColor: ColorTokens.surface,
selectedItemColor: ColorTokens.primary,
unselectedItemColor: ColorTokens.onSurfaceVariant,
selectedLabelStyle: TypographyTokens.labelSmall.copyWith(
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: TypographyTokens.labelSmall,
elevation: 0, // Géré par le Container
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.dashboard_outlined),
activeIcon: Icon(Icons.dashboard),
label: 'Dashboard',
),
BottomNavigationBarItem(
icon: Icon(Icons.people_outline),
activeIcon: Icon(Icons.people),
label: 'Membres',
),
BottomNavigationBarItem(
icon: Icon(Icons.event_outlined),
activeIcon: Icon(Icons.event),
label: 'Événements',
),
BottomNavigationBarItem(
icon: Icon(Icons.more_horiz_outlined),
activeIcon: Icon(Icons.more_horiz),
label: 'Plus',
),
],
),
),
),
);
},
);
}
}
/// Page "Plus" avec les fonctions avancées selon le rôle
class MorePage extends StatelessWidget {
const MorePage({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is! AuthAuthenticated) {
return Container(
color: const Color(0xFFF8F9FA),
child: const Center(child: CircularProgressIndicator()),
);
}
return Container(
color: ColorTokens.background,
child: SingleChildScrollView(
padding: const EdgeInsets.all(SpacingTokens.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de la section
Text(
'Plus d\'Options',
style: TypographyTokens.headlineMedium.copyWith(
color: ColorTokens.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: SpacingTokens.lg),
// Profil utilisateur
_buildUserProfile(state),
const SizedBox(height: 16),
// Options selon le rôle
..._buildRoleBasedOptions(context, state),
const SizedBox(height: 16),
// Options communes
..._buildCommonOptions(context),
],
),
),
);
},
);
}
Widget _buildUserProfile(AuthAuthenticated state) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: const Color(0xFF6C5CE7),
borderRadius: BorderRadius.circular(25),
),
child: Center(
child: Text(
state.user.firstName.isNotEmpty ? state.user.firstName[0].toUpperCase() : 'U',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${state.user.firstName} ${state.user.lastName}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF374151),
),
),
const SizedBox(height: 2),
Text(
state.effectiveRole.displayName,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF6C5CE7),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
state.user.email,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
),
],
),
),
],
),
);
}
List<Widget> _buildRoleBasedOptions(BuildContext context, AuthAuthenticated state) {
final options = <Widget>[];
// Options Super Admin uniquement
if (state.effectiveRole == UserRole.superAdmin) {
options.addAll([
_buildSectionTitle('Administration Système'),
_buildOptionTile(
icon: Icons.people,
title: 'Gestion des utilisateurs',
subtitle: 'Utilisateurs Keycloak et rôles',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const UserManagementPage(),
),
);
},
),
_buildOptionTile(
icon: Icons.settings,
title: 'Paramètres Système',
subtitle: 'Configuration globale',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const SystemSettingsPage(),
),
);
},
),
_buildOptionTile(
icon: Icons.backup,
title: 'Sauvegarde & Restauration',
subtitle: 'Gestion des sauvegardes',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const BackupPage(),
),
);
},
),
_buildOptionTile(
icon: Icons.article,
title: 'Logs & Monitoring',
subtitle: 'Surveillance et journaux',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const LogsPage(),
),
);
},
),
]);
}
// Options Admin+ (Admin Organisation et Super Admin)
if (state.effectiveRole == UserRole.orgAdmin || state.effectiveRole == UserRole.superAdmin) {
options.addAll([
_buildSectionTitle('Rapports & Analytics'),
_buildOptionTile(
icon: Icons.assessment,
title: 'Rapports & Analytics',
subtitle: 'Statistiques détaillées',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ReportsPageWrapper(),
),
);
},
),
]);
}
return options;
}
List<Widget> _buildCommonOptions(BuildContext context) {
return [
_buildSectionTitle('Général'),
_buildOptionTile(
icon: Icons.payment,
title: 'Cotisations',
subtitle: 'Gérer les cotisations',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const CotisationsPageWrapper(),
),
);
},
),
_buildOptionTile(
icon: Icons.how_to_reg,
title: 'Demandes d\'adhésion',
subtitle: 'Demandes d\'adhésion à une organisation',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const AdhesionsPageWrapper(),
),
);
},
),
_buildOptionTile(
icon: Icons.volunteer_activism,
title: 'Demandes d\'aide',
subtitle: 'Solidarité demandes d\'aide',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const DemandesAidePageWrapper(),
),
);
},
),
_buildOptionTile(
icon: Icons.person,
title: 'Mon Profil',
subtitle: 'Modifier mes informations',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ProfilePageWrapper(),
),
);
},
),
_buildOptionTile(
icon: Icons.notifications,
title: 'Notifications',
subtitle: 'Gérer les notifications',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const NotificationsPageWrapper(),
),
);
},
),
_buildOptionTile(
icon: Icons.help,
title: 'Aide & Support',
subtitle: 'Documentation et support',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const HelpSupportPage(),
),
);
},
),
_buildOptionTile(
icon: Icons.info,
title: 'À propos',
subtitle: 'Version et informations',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const AboutPage(),
),
);
},
),
const SizedBox(height: 16),
_buildOptionTile(
icon: Icons.logout,
title: 'Déconnexion',
subtitle: 'Se déconnecter de l\'application',
color: Colors.red,
onTap: () {
context.read<AuthBloc>().add(const AuthLogoutRequested());
},
),
];
}
Widget _buildSectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(
top: 16,
bottom: 8,
),
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
),
);
}
Widget _buildOptionTile({
required IconData icon,
required String title,
required String subtitle,
required VoidCallback onTap,
Color? color,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: (color ?? const Color(0xFF6C5CE7)).withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
icon,
color: color ?? const Color(0xFF6C5CE7),
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: color ?? const Color(0xFF374151),
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
),
],
),
),
const Icon(
Icons.chevron_right,
color: Color(0xFF6B7280),
size: 16,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,214 @@
/// Client HTTP Dio configuré pour l'API UnionFlow
library dio_client;
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../config/environment.dart';
/// Configuration du client HTTP Dio
class DioClient {
static const int _connectTimeout = 30000; // 30 secondes
static const int _receiveTimeout = 30000; // 30 secondes
static const int _sendTimeout = 30000; // 30 secondes
late final Dio _dio;
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
DioClient() {
_dio = Dio();
_configureDio();
}
/// Configuration du client Dio
void _configureDio() {
// Configuration de base - URL depuis AppConfig
_dio.options = BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(milliseconds: _connectTimeout),
receiveTimeout: const Duration(milliseconds: _receiveTimeout),
sendTimeout: const Duration(milliseconds: _sendTimeout),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
// Intercepteur d'authentification
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
// Ajouter le token d'authentification si disponible
final token = await _secureStorage.read(key: 'keycloak_webview_access_token');
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
// Gestion des erreurs d'authentification
if (error.response?.statusCode == 401) {
// Token expiré, essayer de le rafraîchir
final refreshed = await _refreshToken();
if (refreshed) {
// Réessayer la requête avec le nouveau token
final token = await _secureStorage.read(key: 'keycloak_webview_access_token');
if (token != null) {
error.requestOptions.headers['Authorization'] = 'Bearer $token';
final response = await _dio.fetch(error.requestOptions);
handler.resolve(response);
return;
}
}
}
handler.next(error);
},
));
// Logger uniquement en mode développement
if (AppConfig.enableLogging) {
_dio.interceptors.add(
LogInterceptor(
requestHeader: true,
requestBody: true,
responseBody: true,
responseHeader: false,
error: true,
logPrint: (obj) => print('DIO: $obj'),
),
);
}
}
/// Rafraîchit le token d'authentification
Future<bool> _refreshToken() async {
try {
final refreshToken = await _secureStorage.read(key: 'keycloak_webview_refresh_token');
if (refreshToken == null) return false;
final response = await Dio().post(
AppConfig.keycloakTokenUrl,
data: {
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
'client_id': 'unionflow-mobile',
},
options: Options(
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
),
);
if (response.statusCode == 200) {
final data = response.data;
await _secureStorage.write(key: 'keycloak_webview_access_token', value: data['access_token']);
if (data['refresh_token'] != null) {
await _secureStorage.write(key: 'keycloak_webview_refresh_token', value: data['refresh_token']);
}
return true;
}
} catch (e) {
// Erreur lors du rafraîchissement, l'utilisateur devra se reconnecter
}
return false;
}
/// Obtient l'instance Dio configurée
Dio get dio => _dio;
/// Méthodes de convenance pour les requêtes HTTP
/// GET request
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onReceiveProgress,
}) {
return _dio.get<T>(
path,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onReceiveProgress: onReceiveProgress,
);
}
/// POST request
Future<Response<T>> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) {
return _dio.post<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
}
/// PUT request
Future<Response<T>> put<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) {
return _dio.put<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
}
/// DELETE request
Future<Response<T>> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
}) {
return _dio.delete<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
}
/// PATCH request
Future<Response<T>> patch<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) {
return _dio.patch<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:connectivity_plus/connectivity_plus.dart';
/// Interface pour vérifier la connectivité réseau
abstract class NetworkInfo {
Future<bool> get isConnected;
}
/// Implémentation de NetworkInfo utilisant connectivity_plus
class NetworkInfoImpl implements NetworkInfo {
final Connectivity connectivity;
NetworkInfoImpl(this.connectivity);
@override
Future<bool> get isConnected async {
final result = await connectivity.checkConnectivity();
return result.any((r) => r != ConnectivityResult.none);
}
}

View File

@@ -0,0 +1,418 @@
/// Gestionnaire de cache multi-niveaux ultra-performant
/// Cache mémoire + disque avec TTL adaptatif selon les rôles
library dashboard_cache_manager;
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../features/authentication/data/models/user_role.dart';
/// Gestionnaire de cache intelligent avec stratégie multi-niveaux
///
/// Niveaux de cache :
/// 1. Cache mémoire (ultra-rapide, volatile)
/// 2. Cache disque (rapide, persistant)
/// 3. Cache réseau (si applicable)
///
/// Fonctionnalités :
/// - TTL adaptatif selon le rôle utilisateur
/// - Compression automatique des données volumineuses
/// - Invalidation intelligente
/// - Métriques de performance
/// - Nettoyage automatique
class DashboardCacheManager {
static final DashboardCacheManager _instance = DashboardCacheManager._internal();
factory DashboardCacheManager() => _instance;
DashboardCacheManager._internal();
/// Cache mémoire niveau 1 (ultra-rapide)
static final Map<String, _CachedData> _memoryCache = {};
/// Instance SharedPreferences pour le cache disque
static SharedPreferences? _prefs;
/// Taille maximale du cache mémoire (en nombre d'entrées)
static const int _maxMemoryCacheSize = 1000;
/// Taille maximale du cache disque (en MB)
static const int _maxDiskCacheSizeMB = 50;
/// TTL par défaut selon les rôles
static const Map<UserRole, Duration> _roleTTL = {
UserRole.superAdmin: Duration(hours: 2), // Cache plus long pour les admins
UserRole.orgAdmin: Duration(hours: 1), // Cache modéré pour les admins org
UserRole.moderator: Duration(minutes: 30), // Cache court pour les modérateurs
UserRole.activeMember: Duration(minutes: 15), // Cache très court pour les membres
UserRole.simpleMember: Duration(minutes: 10), // Cache minimal
UserRole.visitor: Duration(minutes: 5), // Cache très court pour les visiteurs
};
/// Compteurs de performance
static int _memoryHits = 0;
static int _memoryMisses = 0;
static int _diskHits = 0;
static int _diskMisses = 0;
/// Timer pour le nettoyage automatique
static Timer? _cleanupTimer;
/// Initialise le gestionnaire de cache
static Future<void> initialize() async {
_prefs = await SharedPreferences.getInstance();
// Démarrer le nettoyage automatique toutes les 30 minutes
_cleanupTimer = Timer.periodic(
const Duration(minutes: 30),
(_) => _performAutomaticCleanup(),
);
debugPrint('DashboardCacheManager initialisé');
}
/// Dispose le gestionnaire de cache
static void dispose() {
_cleanupTimer?.cancel();
_memoryCache.clear();
}
/// Récupère une donnée du cache avec stratégie multi-niveaux
///
/// [key] - Clé unique de la donnée
/// [userRole] - Rôle de l'utilisateur pour le TTL adaptatif
/// [fromDisk] - Autoriser la récupération depuis le disque
static Future<T?> get<T>(
String key,
UserRole userRole, {
bool fromDisk = true,
}) async {
// Niveau 1 : Cache mémoire
final memoryData = _getFromMemory<T>(key);
if (memoryData != null) {
_memoryHits++;
return memoryData;
}
_memoryMisses++;
// Niveau 2 : Cache disque
if (fromDisk && _prefs != null) {
final diskData = await _getFromDisk<T>(key, userRole);
if (diskData != null) {
_diskHits++;
// Remettre en cache mémoire pour les prochains accès
await _putInMemory(key, diskData, userRole);
return diskData;
}
_diskMisses++;
}
return null;
}
/// Stocke une donnée dans le cache avec stratégie multi-niveaux
///
/// [key] - Clé unique de la donnée
/// [data] - Donnée à stocker
/// [userRole] - Rôle de l'utilisateur pour le TTL adaptatif
/// [toDisk] - Sauvegarder sur disque
/// [compress] - Compresser les données volumineuses
static Future<void> put<T>(
String key,
T data,
UserRole userRole, {
bool toDisk = true,
bool compress = false,
}) async {
// Niveau 1 : Cache mémoire
await _putInMemory(key, data, userRole);
// Niveau 2 : Cache disque
if (toDisk && _prefs != null) {
await _putOnDisk(key, data, userRole, compress: compress);
}
}
/// Invalide une entrée du cache
static Future<void> invalidate(String key) async {
// Supprimer du cache mémoire
_memoryCache.remove(key);
// Supprimer du cache disque
if (_prefs != null) {
await _prefs!.remove('cache_$key');
await _prefs!.remove('cache_meta_$key');
}
}
/// Invalide toutes les entrées d'un préfixe
static Future<void> invalidatePrefix(String prefix) async {
// Cache mémoire
final keysToRemove = _memoryCache.keys
.where((key) => key.startsWith(prefix))
.toList();
for (final key in keysToRemove) {
_memoryCache.remove(key);
}
// Cache disque
if (_prefs != null) {
final allKeys = _prefs!.getKeys();
final diskKeysToRemove = allKeys
.where((key) => key.startsWith('cache_$prefix'))
.toList();
for (final key in diskKeysToRemove) {
await _prefs!.remove(key);
}
}
}
/// Vide complètement le cache
static Future<void> clear() async {
_memoryCache.clear();
if (_prefs != null) {
final allKeys = _prefs!.getKeys();
final cacheKeys = allKeys.where((key) => key.startsWith('cache_')).toList();
for (final key in cacheKeys) {
await _prefs!.remove(key);
}
}
debugPrint('Cache complètement vidé');
}
/// Obtient les statistiques du cache
static Map<String, dynamic> getStats() {
final totalMemoryRequests = _memoryHits + _memoryMisses;
final totalDiskRequests = _diskHits + _diskMisses;
final memoryHitRate = totalMemoryRequests > 0
? (_memoryHits / totalMemoryRequests * 100)
: 0.0;
final diskHitRate = totalDiskRequests > 0
? (_diskHits / totalDiskRequests * 100)
: 0.0;
return {
'memoryCache': {
'hits': _memoryHits,
'misses': _memoryMisses,
'hitRate': memoryHitRate.toStringAsFixed(2),
'size': _memoryCache.length,
'maxSize': _maxMemoryCacheSize,
},
'diskCache': {
'hits': _diskHits,
'misses': _diskMisses,
'hitRate': diskHitRate.toStringAsFixed(2),
'maxSizeMB': _maxDiskCacheSizeMB,
},
};
}
/// Effectue un nettoyage manuel du cache
static Future<void> cleanup() async {
await _performAutomaticCleanup();
}
// === MÉTHODES PRIVÉES ===
/// Récupère une donnée du cache mémoire
static T? _getFromMemory<T>(String key) {
final cached = _memoryCache[key];
if (cached == null) return null;
// Vérifier l'expiration
if (cached.expiresAt.isBefore(DateTime.now())) {
_memoryCache.remove(key);
return null;
}
return cached.data as T?;
}
/// Stocke une donnée dans le cache mémoire
static Future<void> _putInMemory<T>(String key, T data, UserRole userRole) async {
// Vérifier la taille du cache et nettoyer si nécessaire
if (_memoryCache.length >= _maxMemoryCacheSize) {
await _cleanOldestMemoryEntries();
}
final ttl = _roleTTL[userRole] ?? const Duration(minutes: 5);
_memoryCache[key] = _CachedData(
data: data,
expiresAt: DateTime.now().add(ttl),
createdAt: DateTime.now(),
);
}
/// Récupère une donnée du cache disque
static Future<T?> _getFromDisk<T>(String key, UserRole userRole) async {
if (_prefs == null) return null;
// Récupérer les métadonnées
final metaJson = _prefs!.getString('cache_meta_$key');
if (metaJson == null) return null;
final meta = jsonDecode(metaJson) as Map<String, dynamic>;
final expiresAt = DateTime.parse(meta['expiresAt']);
// Vérifier l'expiration
if (expiresAt.isBefore(DateTime.now())) {
await _prefs!.remove('cache_$key');
await _prefs!.remove('cache_meta_$key');
return null;
}
// Récupérer les données
final dataJson = _prefs!.getString('cache_$key');
if (dataJson == null) return null;
try {
final data = jsonDecode(dataJson);
return data as T;
} catch (e) {
debugPrint('Erreur de désérialisation du cache: $e');
return null;
}
}
/// Stocke une donnée sur le cache disque
static Future<void> _putOnDisk<T>(
String key,
T data,
UserRole userRole, {
bool compress = false,
}) async {
if (_prefs == null) return;
try {
final ttl = _roleTTL[userRole] ?? const Duration(minutes: 5);
final expiresAt = DateTime.now().add(ttl);
// Sérialiser les données
final dataJson = jsonEncode(data);
// Métadonnées
final meta = {
'expiresAt': expiresAt.toIso8601String(),
'createdAt': DateTime.now().toIso8601String(),
'userRole': userRole.name,
'compressed': compress,
};
// Sauvegarder
await _prefs!.setString('cache_$key', dataJson);
await _prefs!.setString('cache_meta_$key', jsonEncode(meta));
} catch (e) {
debugPrint('Erreur de sérialisation du cache: $e');
}
}
/// Nettoie les entrées les plus anciennes du cache mémoire
static Future<void> _cleanOldestMemoryEntries() async {
if (_memoryCache.isEmpty) return;
// Trier par date de création et supprimer les 10% les plus anciennes
final entries = _memoryCache.entries.toList();
entries.sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt));
final toRemove = (entries.length * 0.1).ceil();
for (int i = 0; i < toRemove && i < entries.length; i++) {
_memoryCache.remove(entries[i].key);
}
}
/// Effectue un nettoyage automatique
static Future<void> _performAutomaticCleanup() async {
final now = DateTime.now();
// Nettoyer le cache mémoire expiré
_memoryCache.removeWhere((key, cached) => cached.expiresAt.isBefore(now));
// Nettoyer le cache disque expiré
if (_prefs != null) {
final allKeys = _prefs!.getKeys();
final metaKeys = allKeys.where((key) => key.startsWith('cache_meta_')).toList();
for (final metaKey in metaKeys) {
final metaJson = _prefs!.getString(metaKey);
if (metaJson != null) {
try {
final meta = jsonDecode(metaJson) as Map<String, dynamic>;
final expiresAt = DateTime.parse(meta['expiresAt']);
if (expiresAt.isBefore(now)) {
final dataKey = metaKey.replaceFirst('cache_meta_', 'cache_');
await _prefs!.remove(dataKey);
await _prefs!.remove(metaKey);
}
} catch (e) {
// Supprimer les métadonnées corrompues
await _prefs!.remove(metaKey);
}
}
}
}
debugPrint('Nettoyage automatique du cache effectué');
}
/// Invalide le cache pour un rôle spécifique
static Future<void> invalidateForRole(UserRole role) async {
debugPrint('🗑️ Invalidation du cache pour le rôle: ${role.displayName}');
// Invalider le cache mémoire pour ce rôle
final keysToRemove = <String>[];
for (final key in _memoryCache.keys) {
if (key.contains(role.name)) {
keysToRemove.add(key);
}
}
for (final key in keysToRemove) {
_memoryCache.remove(key);
}
// Invalider le cache disque pour ce rôle
_prefs ??= await SharedPreferences.getInstance();
if (_prefs != null) {
final keys = _prefs!.getKeys();
final diskKeysToRemove = <String>[];
for (final key in keys) {
if (key.startsWith('cache_') && key.contains(role.name)) {
diskKeysToRemove.add(key);
}
}
for (final key in diskKeysToRemove) {
await _prefs!.remove(key);
// Supprimer aussi les métadonnées associées
final metaKey = key.replaceFirst('cache_', 'cache_meta_');
await _prefs!.remove(metaKey);
}
}
debugPrint('✅ Cache invalidé pour le rôle: ${role.displayName}');
}
}
/// Classe pour les données mises en cache
class _CachedData {
final dynamic data;
final DateTime expiresAt;
final DateTime createdAt;
_CachedData({
required this.data,
required this.expiresAt,
required this.createdAt,
});
}

View File

@@ -0,0 +1,17 @@
import 'package:dartz/dartz.dart';
import '../error/failures.dart';
/// Interface de base pour tous les cas d'usage
abstract class UseCase<Type, Params> {
Future<Either<Failure, Type>> call(Params params);
}
/// Cas d'usage sans paramètres
abstract class NoParamsUseCase<Type> {
Future<Either<Failure, Type>> call();
}
/// Classe pour représenter l'absence de paramètres
class NoParams {
const NoParams();
}

View File

@@ -0,0 +1,302 @@
/// Logger centralisé pour l'application
library logger;
import 'package:flutter/foundation.dart';
import '../config/environment.dart';
import '../constants/app_constants.dart';
/// Niveaux de log
enum LogLevel {
debug,
info,
warning,
error,
fatal,
}
/// Logger centralisé pour toute l'application
class AppLogger {
// Empêcher l'instanciation
AppLogger._();
/// Couleurs ANSI pour les logs en console
static const String _reset = '\x1B[0m';
static const String _red = '\x1B[31m';
static const String _green = '\x1B[32m';
static const String _yellow = '\x1B[33m';
static const String _blue = '\x1B[34m';
static const String _magenta = '\x1B[35m';
static const String _cyan = '\x1B[36m';
static const String _white = '\x1B[37m';
/// Log de niveau DEBUG (bleu)
static void debug(String message, {String? tag}) {
if (AppConfig.enableLogging && kDebugMode) {
_log(LogLevel.debug, message, tag: tag, color: _blue);
}
}
/// Log de niveau INFO (vert)
static void info(String message, {String? tag}) {
if (AppConfig.enableLogging && kDebugMode) {
_log(LogLevel.info, message, tag: tag, color: _green);
}
}
/// Log de niveau WARNING (jaune)
static void warning(String message, {String? tag}) {
if (AppConfig.enableLogging && kDebugMode) {
_log(LogLevel.warning, message, tag: tag, color: _yellow);
}
}
/// Log de niveau ERROR (rouge)
static void error(
String message, {
String? tag,
dynamic error,
StackTrace? stackTrace,
}) {
if (AppConfig.enableLogging) {
_log(LogLevel.error, message, tag: tag, color: _red);
if (error != null) {
_log(LogLevel.error, 'Error: $error', tag: tag, color: _red);
}
if (stackTrace != null) {
_log(LogLevel.error, 'StackTrace:\n$stackTrace', tag: tag, color: _red);
}
// Envoi au service de monitoring si configuré
if (AppConfig.enableCrashReporting) {
_sendToMonitoring(message, error, stackTrace);
}
}
}
/// Log de niveau FATAL (magenta)
static void fatal(
String message, {
String? tag,
dynamic error,
StackTrace? stackTrace,
}) {
if (AppConfig.enableLogging) {
_log(LogLevel.fatal, message, tag: tag, color: _magenta);
if (error != null) {
_log(LogLevel.fatal, 'Error: $error', tag: tag, color: _magenta);
}
if (stackTrace != null) {
_log(LogLevel.fatal, 'StackTrace:\n$stackTrace', tag: tag, color: _magenta);
}
// Envoi au service de monitoring si configuré
if (AppConfig.enableCrashReporting) {
_sendToMonitoring(message, error, stackTrace, isFatal: true);
}
}
}
/// Log d'une requête HTTP
static void httpRequest({
required String method,
required String url,
Map<String, dynamic>? headers,
dynamic body,
}) {
if (AppConfig.enableLogging && kDebugMode) {
final buffer = StringBuffer();
buffer.writeln('┌─────────────────────────────────────────────────');
buffer.writeln('│ HTTP REQUEST');
buffer.writeln('├─────────────────────────────────────────────────');
buffer.writeln('│ Method: $method');
buffer.writeln('│ URL: $url');
if (headers != null && headers.isNotEmpty) {
buffer.writeln('│ Headers:');
headers.forEach((key, value) {
buffer.writeln('$key: $value');
});
}
if (body != null) {
buffer.writeln('│ Body: $body');
}
buffer.writeln('└─────────────────────────────────────────────────');
_log(LogLevel.debug, buffer.toString(), color: _cyan);
}
}
/// Log d'une réponse HTTP
static void httpResponse({
required int statusCode,
required String url,
Map<String, dynamic>? headers,
dynamic body,
Duration? duration,
}) {
if (AppConfig.enableLogging && kDebugMode) {
final buffer = StringBuffer();
buffer.writeln('┌─────────────────────────────────────────────────');
buffer.writeln('│ HTTP RESPONSE');
buffer.writeln('├─────────────────────────────────────────────────');
buffer.writeln('│ Status: $statusCode');
buffer.writeln('│ URL: $url');
if (duration != null) {
buffer.writeln('│ Duration: ${duration.inMilliseconds}ms');
}
if (headers != null && headers.isNotEmpty) {
buffer.writeln('│ Headers:');
headers.forEach((key, value) {
buffer.writeln('$key: $value');
});
}
if (body != null) {
buffer.writeln('│ Body: $body');
}
buffer.writeln('└─────────────────────────────────────────────────');
final color = statusCode >= 200 && statusCode < 300 ? _green : _red;
_log(LogLevel.debug, buffer.toString(), color: color);
}
}
/// Log d'un événement BLoC
static void blocEvent(String blocName, String eventName, {dynamic data}) {
if (AppConfig.enableLogging && kDebugMode) {
final message = data != null
? '[$blocName] Event: $eventName | Data: $data'
: '[$blocName] Event: $eventName';
_log(LogLevel.debug, message, color: _cyan);
}
}
/// Log d'un changement d'état BLoC
static void blocState(String blocName, String stateName, {dynamic data}) {
if (AppConfig.enableLogging && kDebugMode) {
final message = data != null
? '[$blocName] State: $stateName | Data: $data'
: '[$blocName] State: $stateName';
_log(LogLevel.debug, message, color: _magenta);
}
}
/// Log d'une navigation
static void navigation(String from, String to) {
if (AppConfig.enableLogging && kDebugMode) {
_log(LogLevel.debug, 'Navigation: $from$to', color: _yellow);
}
}
/// Log d'une action utilisateur
static void userAction(String action, {Map<String, dynamic>? data}) {
if (AppConfig.enableLogging && kDebugMode) {
final message = data != null
? 'User Action: $action | Data: $data'
: 'User Action: $action';
_log(LogLevel.info, message, color: _green);
}
// Envoi au service d'analytics si configuré
if (AppConfig.enableAnalytics) {
_sendToAnalytics(action, data);
}
}
/// Méthode privée pour logger avec formatage
static void _log(
LogLevel level,
String message, {
String? tag,
String color = _white,
}) {
final timestamp = DateTime.now().toIso8601String();
final levelStr = level.name.toUpperCase().padRight(7);
final tagStr = tag != null ? '[$tag] ' : '';
if (kDebugMode) {
// En mode debug, utiliser les couleurs
debugPrint('$color$timestamp | $levelStr | $tagStr$message$_reset');
} else {
// En mode release, pas de couleurs
debugPrint('$timestamp | $levelStr | $tagStr$message');
}
}
/// Envoyer les erreurs à un service de monitoring
static void _sendToMonitoring(
String message,
dynamic error,
StackTrace? stackTrace, {
bool isFatal = false,
}) {
// Stub — implémenter avec Sentry ou Firebase Crashlytics quand intégré
// Exemple avec Sentry:
// Sentry.captureException(
// error,
// stackTrace: stackTrace,
// hint: Hint.withMap({'message': message}),
// );
}
/// Envoyer les événements à un service d'analytics
static void _sendToAnalytics(String action, Map<String, dynamic>? data) {
// Stub — implémenter avec Firebase Analytics ou Mixpanel quand intégré
// Exemple avec Firebase Analytics:
// FirebaseAnalytics.instance.logEvent(
// name: action,
// parameters: data,
// );
}
/// Divider pour séparer visuellement les logs
static void divider({String? title}) {
if (AppConfig.enableLogging && kDebugMode) {
if (title != null) {
debugPrint('$_cyan═══════════════════════════════════════════════════$_reset');
debugPrint('$_cyan $title$_reset');
debugPrint('$_cyan═══════════════════════════════════════════════════$_reset');
} else {
debugPrint('$_cyan═══════════════════════════════════════════════════$_reset');
}
}
}
}
/// Extension pour faciliter le logging depuis n'importe où
extension LoggerExtension on Object {
/// Log debug
void logDebug(String message) {
AppLogger.debug(message, tag: runtimeType.toString());
}
/// Log info
void logInfo(String message) {
AppLogger.info(message, tag: runtimeType.toString());
}
/// Log warning
void logWarning(String message) {
AppLogger.warning(message, tag: runtimeType.toString());
}
/// Log error
void logError(String message, {dynamic error, StackTrace? stackTrace}) {
AppLogger.error(
message,
tag: runtimeType.toString(),
error: error,
stackTrace: stackTrace,
);
}
}

View File

@@ -0,0 +1,873 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../shared/design_system/tokens/color_tokens.dart';
import '../../../../shared/design_system/tokens/spacing_tokens.dart';
/// Page À propos - UnionFlow Mobile
///
/// Page d'informations sur l'application, version, équipe de développement,
/// liens utiles et fonctionnalités de support.
class AboutPage extends StatefulWidget {
const AboutPage({super.key});
@override
State<AboutPage> createState() => _AboutPageState();
}
class _AboutPageState extends State<AboutPage> {
PackageInfo? _packageInfo;
@override
void initState() {
super.initState();
_loadPackageInfo();
}
Future<void> _loadPackageInfo() async {
final info = await PackageInfo.fromPlatform();
setState(() {
_packageInfo = info;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FA),
body: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header harmonisé
_buildHeader(),
const SizedBox(height: 16),
// Informations de l'application
_buildAppInfoSection(),
const SizedBox(height: 16),
// Équipe de développement
_buildTeamSection(),
const SizedBox(height: 16),
// Fonctionnalités
_buildFeaturesSection(),
const SizedBox(height: 16),
// Liens utiles
_buildLinksSection(),
const SizedBox(height: 16),
// Support et contact
_buildSupportSection(),
const SizedBox(height: 80),
],
),
),
);
}
/// Header harmonisé avec le design system
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(SpacingTokens.xl),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: ColorTokens.primaryGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(SpacingTokens.xl),
boxShadow: [
BoxShadow(
color: ColorTokens.primary.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.info,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'À propos de UnionFlow',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Text(
'Version et informations de l\'application',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.8),
),
),
],
),
),
],
),
);
}
/// Section informations de l'application
Widget _buildAppInfoSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.mobile_friendly,
color: Colors.grey[600],
size: 20,
),
const SizedBox(width: 8),
Text(
'Informations de l\'application',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 16),
// Logo et nom de l'app
Center(
child: Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: ColorTokens.primaryGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(SpacingTokens.xxl),
),
child: const Icon(
Icons.account_balance,
color: Colors.white,
size: 40,
),
),
const SizedBox(height: 12),
const Text(
'UnionFlow Mobile',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Color(0xFF1F2937),
),
),
const SizedBox(height: 4),
Text(
'Gestion d\'associations et syndicats',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
const SizedBox(height: 20),
// Informations techniques
_buildInfoRow('Version', _packageInfo?.version ?? 'Chargement...'),
_buildInfoRow('Build', _packageInfo?.buildNumber ?? 'Chargement...'),
_buildInfoRow('Package', _packageInfo?.packageName ?? 'Chargement...'),
_buildInfoRow('Plateforme', 'Android/iOS'),
_buildInfoRow('Framework', 'Flutter 3.x'),
],
),
);
}
/// Ligne d'information
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
Flexible(
child: Text(
value,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1F2937),
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.end,
),
),
],
),
);
}
/// Section équipe de développement
Widget _buildTeamSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.group,
color: Colors.grey[600],
size: 20,
),
const SizedBox(width: 8),
Text(
'Équipe de développement',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 16),
_buildTeamMember(
'UnionFlow Team',
'Développement & Architecture',
Icons.code,
ColorTokens.primary,
),
_buildTeamMember(
'Design System',
'Interface utilisateur & UX',
Icons.design_services,
ColorTokens.info,
),
_buildTeamMember(
'Support Technique',
'Maintenance & Support',
Icons.support_agent,
ColorTokens.success,
),
],
),
);
}
/// Membre de l'équipe
Widget _buildTeamMember(String name, String role, IconData icon, Color color) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1F2937),
),
),
Text(
role,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
],
),
);
}
/// Section fonctionnalités
Widget _buildFeaturesSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.featured_play_list,
color: Colors.grey[600],
size: 20,
),
const SizedBox(width: 8),
Text(
'Fonctionnalités principales',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 16),
_buildFeatureItem(
'Gestion des membres',
'Administration complète des adhérents',
Icons.people,
ColorTokens.primary,
),
_buildFeatureItem(
'Organisations',
'Gestion des syndicats et fédérations',
Icons.business,
ColorTokens.info,
),
_buildFeatureItem(
'Événements',
'Planification et suivi des événements',
Icons.event,
ColorTokens.success,
),
_buildFeatureItem(
'Tableau de bord',
'Statistiques et métriques en temps réel',
Icons.dashboard,
ColorTokens.warning,
),
_buildFeatureItem(
'Authentification sécurisée',
'Connexion via Keycloak OIDC',
Icons.security,
ColorTokens.tertiary,
),
],
),
);
}
/// Élément de fonctionnalité
Widget _buildFeatureItem(String title, String description, IconData icon, Color color) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1F2937),
),
),
Text(
description,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
],
),
);
}
/// Section liens utiles
Widget _buildLinksSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.link,
color: Colors.grey[600],
size: 20,
),
const SizedBox(width: 8),
Text(
'Liens utiles',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 16),
_buildLinkItem(
'Site web officiel',
'https://unionflow.com',
Icons.web,
() => _launchUrl('https://unionflow.com'),
),
_buildLinkItem(
'Documentation',
'Guide d\'utilisation complet',
Icons.book,
() => _launchUrl('https://docs.unionflow.com'),
),
_buildLinkItem(
'Code source',
'Projet open source sur GitHub',
Icons.code,
() => _launchUrl('https://github.com/unionflow/unionflow'),
),
_buildLinkItem(
'Politique de confidentialité',
'Protection de vos données',
Icons.privacy_tip,
() => _launchUrl('https://unionflow.com/privacy'),
),
],
),
);
}
/// Élément de lien
Widget _buildLinkItem(String title, String subtitle, IconData icon, VoidCallback onTap) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: ColorTokens.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(SpacingTokens.md),
),
child: Icon(
icon,
color: ColorTokens.primary,
size: 20,
),
),
const SizedBox(width: SpacingTokens.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1F2937),
),
),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
Icon(
Icons.arrow_forward_ios,
color: Colors.grey[400],
size: 16,
),
],
),
),
);
}
/// Section support et contact
Widget _buildSupportSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.support_agent,
color: Colors.grey[600],
size: 20,
),
const SizedBox(width: 8),
Text(
'Support et contact',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 16),
_buildSupportItem(
'Support technique',
'support@unionflow.com',
Icons.email,
() => _launchUrl('mailto:support@unionflow.com'),
),
_buildSupportItem(
'Signaler un bug',
'Rapporter un problème technique',
Icons.bug_report,
() => _showBugReportDialog(),
),
_buildSupportItem(
'Suggérer une amélioration',
'Proposer de nouvelles fonctionnalités',
Icons.lightbulb,
() => _showFeatureRequestDialog(),
),
_buildSupportItem(
'Évaluer l\'application',
'Donner votre avis sur les stores',
Icons.star,
() => _showRatingDialog(),
),
const SizedBox(height: 20),
// Copyright et mentions légales
Center(
child: Column(
children: [
Text(
'© 2024 UnionFlow. Tous droits réservés.',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
'Développé avec ❤️ pour les organisations syndicales',
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
textAlign: TextAlign.center,
),
],
),
),
],
),
);
}
/// Élément de support
Widget _buildSupportItem(String title, String subtitle, IconData icon, VoidCallback onTap) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF00B894).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: const Color(0xFF00B894),
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1F2937),
),
),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
Icon(
Icons.arrow_forward_ios,
color: Colors.grey[400],
size: 16,
),
],
),
),
);
}
/// Lancer une URL
Future<void> _launchUrl(String url) async {
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
_showErrorSnackBar('Impossible d\'ouvrir le lien');
}
} catch (e) {
_showErrorSnackBar('Erreur lors de l\'ouverture du lien');
}
}
/// Afficher le dialogue de rapport de bug
void _showBugReportDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Signaler un bug'),
content: const Text(
'Pour signaler un bug, veuillez envoyer un email à support@unionflow.com '
'en décrivant le problème rencontré et les étapes pour le reproduire.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_launchUrl('mailto:support@unionflow.com?subject=Rapport de bug - UnionFlow Mobile');
},
style: ElevatedButton.styleFrom(
backgroundColor: ColorTokens.primary,
foregroundColor: ColorTokens.onPrimary,
),
child: const Text('Envoyer un email'),
),
],
),
);
}
/// Afficher le dialogue de demande de fonctionnalité
void _showFeatureRequestDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Suggérer une amélioration'),
content: const Text(
'Nous sommes toujours à l\'écoute de vos suggestions ! '
'Envoyez-nous vos idées d\'amélioration par email.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_launchUrl('mailto:support@unionflow.com?subject=Suggestion d\'amélioration - UnionFlow Mobile');
},
style: ElevatedButton.styleFrom(
backgroundColor: ColorTokens.primary,
foregroundColor: ColorTokens.onPrimary,
),
child: const Text('Envoyer une suggestion'),
),
],
),
);
}
/// Afficher le dialogue d'évaluation
void _showRatingDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Évaluer l\'application'),
content: const Text(
'Votre avis nous aide à améliorer UnionFlow ! '
'Prenez quelques secondes pour évaluer l\'application sur votre store.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Plus tard'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
// Ici on pourrait utiliser un package comme in_app_review
_showErrorSnackBar('Fonctionnalité bientôt disponible');
},
style: ElevatedButton.styleFrom(
backgroundColor: ColorTokens.primary,
foregroundColor: ColorTokens.onPrimary,
),
child: const Text('Évaluer maintenant'),
),
],
),
);
}
/// Afficher un message d'erreur
void _showErrorSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: const Color(0xFFE74C3C),
behavior: SnackBarBehavior.floating,
),
);
}
}

View File

@@ -0,0 +1,121 @@
/// BLoC pour la gestion des adhésions (demandes d'adhésion)
library adhesions_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import '../data/models/adhesion_model.dart';
import '../data/repositories/adhesion_repository.dart';
part 'adhesions_event.dart';
part 'adhesions_state.dart';
class AdhesionsBloc extends Bloc<AdhesionsEvent, AdhesionsState> {
final AdhesionRepository _repository;
AdhesionsBloc(this._repository) : super(const AdhesionsState()) {
on<LoadAdhesions>(_onLoadAdhesions);
on<LoadAdhesionsEnAttente>(_onLoadAdhesionsEnAttente);
on<LoadAdhesionsByStatut>(_onLoadAdhesionsByStatut);
on<LoadAdhesionById>(_onLoadAdhesionById);
on<CreateAdhesion>(_onCreateAdhesion);
on<ApprouverAdhesion>(_onApprouverAdhesion);
on<RejeterAdhesion>(_onRejeterAdhesion);
on<EnregistrerPaiementAdhesion>(_onEnregistrerPaiementAdhesion);
on<LoadAdhesionsStats>(_onLoadAdhesionsStats);
}
Future<void> _onLoadAdhesions(LoadAdhesions event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Chargement...'));
try {
final list = await _repository.getAll(page: event.page, size: event.size);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionsEnAttente(LoadAdhesionsEnAttente event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Chargement...'));
try {
final list = await _repository.getEnAttente(page: event.page, size: event.size);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionsByStatut(LoadAdhesionsByStatut event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Chargement...'));
try {
final list = await _repository.getByStatut(event.statut, page: event.page, size: event.size);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionById(LoadAdhesionById event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading));
try {
final adhesion = await _repository.getById(event.id);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: adhesion));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onCreateAdhesion(CreateAdhesion event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Création...'));
try {
await _repository.create(event.adhesion);
add(const LoadAdhesions());
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onApprouverAdhesion(ApprouverAdhesion event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading));
try {
final updated = await _repository.approuver(event.id, approuvePar: event.approuvePar);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: updated));
add(const LoadAdhesions());
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onRejeterAdhesion(RejeterAdhesion event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading));
try {
final updated = await _repository.rejeter(event.id, event.motifRejet);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: updated));
add(const LoadAdhesions());
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onEnregistrerPaiementAdhesion(EnregistrerPaiementAdhesion event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading));
try {
final updated = await _repository.enregistrerPaiement(
event.id,
montantPaye: event.montantPaye,
methodePaiement: event.methodePaiement,
referencePaiement: event.referencePaiement,
);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: updated));
add(const LoadAdhesions());
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionsStats(LoadAdhesionsStats event, Emitter<AdhesionsState> emit) async {
try {
final stats = await _repository.getStats();
emit(state.copyWith(stats: stats));
} catch (_) {}
}
}

View File

@@ -0,0 +1,81 @@
part of 'adhesions_bloc.dart';
abstract class AdhesionsEvent extends Equatable {
const AdhesionsEvent();
@override
List<Object?> get props => [];
}
class LoadAdhesions extends AdhesionsEvent {
final int page;
final int size;
const LoadAdhesions({this.page = 0, this.size = 20});
@override
List<Object?> get props => [page, size];
}
class LoadAdhesionsEnAttente extends AdhesionsEvent {
final int page;
final int size;
const LoadAdhesionsEnAttente({this.page = 0, this.size = 20});
@override
List<Object?> get props => [page, size];
}
class LoadAdhesionsByStatut extends AdhesionsEvent {
final String statut;
final int page;
final int size;
const LoadAdhesionsByStatut(this.statut, {this.page = 0, this.size = 20});
@override
List<Object?> get props => [statut, page, size];
}
class LoadAdhesionById extends AdhesionsEvent {
final String id;
const LoadAdhesionById(this.id);
@override
List<Object?> get props => [id];
}
class CreateAdhesion extends AdhesionsEvent {
final AdhesionModel adhesion;
const CreateAdhesion(this.adhesion);
@override
List<Object?> get props => [adhesion];
}
class ApprouverAdhesion extends AdhesionsEvent {
final String id;
final String? approuvePar;
const ApprouverAdhesion(this.id, {this.approuvePar});
@override
List<Object?> get props => [id, approuvePar];
}
class RejeterAdhesion extends AdhesionsEvent {
final String id;
final String motifRejet;
const RejeterAdhesion(this.id, this.motifRejet);
@override
List<Object?> get props => [id, motifRejet];
}
class EnregistrerPaiementAdhesion extends AdhesionsEvent {
final String id;
final double montantPaye;
final String? methodePaiement;
final String? referencePaiement;
const EnregistrerPaiementAdhesion(
this.id, {
required this.montantPaye,
this.methodePaiement,
this.referencePaiement,
});
@override
List<Object?> get props => [id, montantPaye, methodePaiement, referencePaiement];
}
class LoadAdhesionsStats extends AdhesionsEvent {
const LoadAdhesionsStats();
}

View File

@@ -0,0 +1,42 @@
part of 'adhesions_bloc.dart';
enum AdhesionsStatus { initial, loading, loaded, error }
class AdhesionsState extends Equatable {
final AdhesionsStatus status;
final List<AdhesionModel> adhesions;
final AdhesionModel? adhesionDetail;
final Map<String, dynamic>? stats;
final String? message;
final Object? error;
const AdhesionsState({
this.status = AdhesionsStatus.initial,
this.adhesions = const [],
this.adhesionDetail,
this.stats,
this.message,
this.error,
});
AdhesionsState copyWith({
AdhesionsStatus? status,
List<AdhesionModel>? adhesions,
AdhesionModel? adhesionDetail,
Map<String, dynamic>? stats,
String? message,
Object? error,
}) {
return AdhesionsState(
status: status ?? this.status,
adhesions: adhesions ?? this.adhesions,
adhesionDetail: adhesionDetail ?? this.adhesionDetail,
stats: stats ?? this.stats,
message: message ?? this.message,
error: error ?? this.error,
);
}
@override
List<Object?> get props => [status, adhesions, adhesionDetail, stats, message, error];
}

View File

@@ -0,0 +1,139 @@
/// Modèle de données pour les adhésions (demandes d'adhésion à une organisation)
/// Correspond à l'API AdhesionResource / AdhesionDTO
library adhesion_model;
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'adhesion_model.g.dart';
/// Statut d'une demande d'adhésion
enum StatutAdhesion {
@JsonValue('EN_ATTENTE')
enAttente,
@JsonValue('APPROUVEE')
approuvee,
@JsonValue('REJETEE')
rejetee,
@JsonValue('ANNULEE')
annulee,
@JsonValue('EN_PAIEMENT')
enPaiement,
@JsonValue('PAYEE')
payee,
}
/// Modèle d'une adhésion
@JsonSerializable(explicitToJson: true)
class AdhesionModel extends Equatable {
final String? id;
final String? numeroReference;
final String? membreId;
final String? numeroMembre;
final String? nomMembre;
final String? emailMembre;
final String? organisationId;
final String? nomOrganisation;
final DateTime? dateDemande;
final double? fraisAdhesion;
final double? montantPaye;
final String? codeDevise;
final String? statut;
final DateTime? dateApprobation;
final DateTime? datePaiement;
final String? methodePaiement;
final String? referencePaiement;
final String? motifRejet;
final String? observations;
final String? approuvePar;
final DateTime? dateCreation;
final DateTime? dateModification;
const AdhesionModel({
this.id,
this.numeroReference,
this.membreId,
this.numeroMembre,
this.nomMembre,
this.emailMembre,
this.organisationId,
this.nomOrganisation,
this.dateDemande,
this.fraisAdhesion,
this.montantPaye,
this.codeDevise,
this.statut,
this.dateApprobation,
this.datePaiement,
this.methodePaiement,
this.referencePaiement,
this.motifRejet,
this.observations,
this.approuvePar,
this.dateCreation,
this.dateModification,
});
factory AdhesionModel.fromJson(Map<String, dynamic> json) =>
_$AdhesionModelFromJson(json);
Map<String, dynamic> toJson() => _$AdhesionModelToJson(this);
/// Montant restant à payer
double get montantRestant {
if (fraisAdhesion == null) return 0;
final paye = montantPaye ?? 0;
final restant = fraisAdhesion! - paye;
return restant > 0 ? restant : 0;
}
/// Pourcentage payé
int get pourcentagePaiement {
if (fraisAdhesion == null || fraisAdhesion! == 0) return 0;
if (montantPaye == null) return 0;
return ((montantPaye! / fraisAdhesion!) * 100).round();
}
bool get estPayeeIntegralement =>
fraisAdhesion != null &&
montantPaye != null &&
montantPaye! >= fraisAdhesion!;
bool get estEnAttentePaiement =>
statut == 'APPROUVEE' && !estPayeeIntegralement;
String get statutLibelle {
switch (statut) {
case 'EN_ATTENTE':
return 'En attente';
case 'APPROUVEE':
return 'Approuvée';
case 'REJETEE':
return 'Rejetée';
case 'ANNULEE':
return 'Annulée';
case 'EN_PAIEMENT':
return 'En paiement';
case 'PAYEE':
return 'Payée';
default:
return statut ?? 'Non défini';
}
}
String get nomMembreComplet =>
[nomMembre, numeroMembre].where((e) => e != null && e.isNotEmpty).join(' ').trim().isEmpty
? (emailMembre ?? 'Membre')
: [nomMembre, numeroMembre].where((e) => e != null && e.isNotEmpty).join(' ').trim();
@override
List<Object?> get props => [
id,
numeroReference,
membreId,
organisationId,
statut,
dateDemande,
fraisAdhesion,
montantPaye,
];
}

View File

@@ -0,0 +1,69 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'adhesion_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AdhesionModel _$AdhesionModelFromJson(Map<String, dynamic> json) =>
AdhesionModel(
id: json['id'] as String?,
numeroReference: json['numeroReference'] as String?,
membreId: json['membreId'] as String?,
numeroMembre: json['numeroMembre'] as String?,
nomMembre: json['nomMembre'] as String?,
emailMembre: json['emailMembre'] as String?,
organisationId: json['organisationId'] as String?,
nomOrganisation: json['nomOrganisation'] as String?,
dateDemande: json['dateDemande'] == null
? null
: DateTime.parse(json['dateDemande'] as String),
fraisAdhesion: (json['fraisAdhesion'] as num?)?.toDouble(),
montantPaye: (json['montantPaye'] as num?)?.toDouble(),
codeDevise: json['codeDevise'] as String?,
statut: json['statut'] as String?,
dateApprobation: json['dateApprobation'] == null
? null
: DateTime.parse(json['dateApprobation'] as String),
datePaiement: json['datePaiement'] == null
? null
: DateTime.parse(json['datePaiement'] as String),
methodePaiement: json['methodePaiement'] as String?,
referencePaiement: json['referencePaiement'] as String?,
motifRejet: json['motifRejet'] as String?,
observations: json['observations'] as String?,
approuvePar: json['approuvePar'] as String?,
dateCreation: json['dateCreation'] == null
? null
: DateTime.parse(json['dateCreation'] as String),
dateModification: json['dateModification'] == null
? null
: DateTime.parse(json['dateModification'] as String),
);
Map<String, dynamic> _$AdhesionModelToJson(AdhesionModel instance) =>
<String, dynamic>{
'id': instance.id,
'numeroReference': instance.numeroReference,
'membreId': instance.membreId,
'numeroMembre': instance.numeroMembre,
'nomMembre': instance.nomMembre,
'emailMembre': instance.emailMembre,
'organisationId': instance.organisationId,
'nomOrganisation': instance.nomOrganisation,
'dateDemande': instance.dateDemande?.toIso8601String(),
'fraisAdhesion': instance.fraisAdhesion,
'montantPaye': instance.montantPaye,
'codeDevise': instance.codeDevise,
'statut': instance.statut,
'dateApprobation': instance.dateApprobation?.toIso8601String(),
'datePaiement': instance.datePaiement?.toIso8601String(),
'methodePaiement': instance.methodePaiement,
'referencePaiement': instance.referencePaiement,
'motifRejet': instance.motifRejet,
'observations': instance.observations,
'approuvePar': instance.approuvePar,
'dateCreation': instance.dateCreation?.toIso8601String(),
'dateModification': instance.dateModification?.toIso8601String(),
};

View File

@@ -0,0 +1,178 @@
/// Repository pour la gestion des adhésions (demandes d'adhésion)
/// Interface avec l'API backend AdhesionResource
library adhesion_repository;
import 'package:dio/dio.dart';
import '../models/adhesion_model.dart';
abstract class AdhesionRepository {
Future<List<AdhesionModel>> getAll({int page = 0, int size = 20});
Future<AdhesionModel?> getById(String id);
Future<AdhesionModel> create(AdhesionModel adhesion);
Future<AdhesionModel> approuver(String id, {String? approuvePar});
Future<AdhesionModel> rejeter(String id, String motifRejet);
Future<AdhesionModel> enregistrerPaiement(
String id, {
required double montantPaye,
String? methodePaiement,
String? referencePaiement,
});
Future<List<AdhesionModel>> getByMembre(String membreId, {int page = 0, int size = 20});
Future<List<AdhesionModel>> getByOrganisation(String organisationId, {int page = 0, int size = 20});
Future<List<AdhesionModel>> getByStatut(String statut, {int page = 0, int size = 20});
Future<List<AdhesionModel>> getEnAttente({int page = 0, int size = 20});
Future<Map<String, dynamic>?> getStats();
}
class AdhesionRepositoryImpl implements AdhesionRepository {
final Dio _dio;
static const String _base = '/api/adhesions';
AdhesionRepositoryImpl(this._dio);
@override
Future<List<AdhesionModel>> getAll({int page = 0, int size = 20}) async {
final response = await _dio.get(
_base,
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data as List<dynamic>;
return data
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<AdhesionModel?> getById(String id) async {
final response = await _dio.get('$_base/$id');
if (response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
if (response.statusCode == 404) return null;
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<AdhesionModel> create(AdhesionModel adhesion) async {
final body = adhesion.toJson();
// Backend attend membreId, organisationId, fraisAdhesion, codeDevise (optionnel)
final response = await _dio.post(_base, data: body);
if (response.statusCode == 201 || response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur création: ${response.statusCode}');
}
@override
Future<AdhesionModel> approuver(String id, {String? approuvePar}) async {
final response = await _dio.post(
'$_base/$id/approuver',
queryParameters: approuvePar != null ? {'approuvePar': approuvePar} : null,
);
if (response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur approbation: ${response.statusCode}');
}
@override
Future<AdhesionModel> rejeter(String id, String motifRejet) async {
final response = await _dio.post(
'$_base/$id/rejeter',
queryParameters: {'motifRejet': motifRejet},
);
if (response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur rejet: ${response.statusCode}');
}
@override
Future<AdhesionModel> enregistrerPaiement(
String id, {
required double montantPaye,
String? methodePaiement,
String? referencePaiement,
}) async {
final q = <String, dynamic>{'montantPaye': montantPaye};
if (methodePaiement != null) q['methodePaiement'] = methodePaiement;
if (referencePaiement != null) q['referencePaiement'] = referencePaiement;
final response = await _dio.post('$_base/$id/paiement', queryParameters: q);
if (response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur paiement: ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getByMembre(String membreId, {int page = 0, int size = 20}) async {
final response = await _dio.get(
'$_base/membre/$membreId',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data as List<dynamic>;
return data
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getByOrganisation(String organisationId, {int page = 0, int size = 20}) async {
final response = await _dio.get(
'$_base/organisation/$organisationId',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data as List<dynamic>;
return data
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getByStatut(String statut, {int page = 0, int size = 20}) async {
final response = await _dio.get(
'$_base/statut/$statut',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data as List<dynamic>;
return data
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getEnAttente({int page = 0, int size = 20}) async {
final response = await _dio.get(
'$_base/en-attente',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data as List<dynamic>;
return data
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<Map<String, dynamic>?> getStats() async {
final response = await _dio.get('$_base/stats');
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
}
return null;
}
}

View File

@@ -0,0 +1,16 @@
/// Configuration de l'injection de dépendances pour le module Adhésions
library adhesions_di;
import 'package:get_it/get_it.dart';
import 'package:dio/dio.dart';
import '../bloc/adhesions_bloc.dart';
import '../data/repositories/adhesion_repository.dart';
void registerAdhesionsDependencies(GetIt getIt) {
getIt.registerLazySingleton<AdhesionRepository>(
() => AdhesionRepositoryImpl(getIt<Dio>()),
);
getIt.registerFactory<AdhesionsBloc>(
() => AdhesionsBloc(getIt<AdhesionRepository>()),
);
}

View File

@@ -0,0 +1,245 @@
/// Page détail d'une demande d'adhésion + actions (approuver, rejeter, paiement)
library adhesion_detail_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../bloc/adhesions_bloc.dart';
import '../../data/models/adhesion_model.dart';
import '../widgets/paiement_adhesion_dialog.dart';
import '../widgets/rejet_adhesion_dialog.dart';
class AdhesionDetailPage extends StatefulWidget {
final String adhesionId;
const AdhesionDetailPage({super.key, required this.adhesionId});
@override
State<AdhesionDetailPage> createState() => _AdhesionDetailPageState();
}
class _AdhesionDetailPageState extends State<AdhesionDetailPage> {
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA');
@override
void initState() {
super.initState();
context.read<AdhesionsBloc>().add(LoadAdhesionById(widget.adhesionId));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Détail adhésion'),
),
body: BlocConsumer<AdhesionsBloc, AdhesionsState>(
listenWhen: (prev, curr) => prev.status != curr.status,
listener: (context, state) {
if (state.status == AdhesionsStatus.error && state.message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message!), backgroundColor: Colors.red),
);
}
},
buildWhen: (prev, curr) =>
prev.adhesionDetail != curr.adhesionDetail || prev.status != curr.status,
builder: (context, state) {
if (state.status == AdhesionsStatus.loading && state.adhesionDetail == null) {
return const Center(child: CircularProgressIndicator());
}
final a = state.adhesionDetail;
if (a == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(
'Adhésion introuvable',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoCard(
title: 'Référence',
value: a.numeroReference ?? a.id ?? '',
),
const SizedBox(height: 12),
_InfoCard(title: 'Statut', value: a.statutLibelle),
const SizedBox(height: 12),
_InfoCard(
title: 'Organisation',
value: a.nomOrganisation ?? a.organisationId ?? '',
),
_InfoCard(
title: 'Membre',
value: a.nomMembreComplet,
),
if (a.emailMembre != null && a.emailMembre!.isNotEmpty)
_InfoCard(title: 'Email', value: a.emailMembre!),
if (a.dateDemande != null)
_InfoCard(
title: 'Date demande',
value: DateFormat('dd/MM/yyyy').format(a.dateDemande!),
),
_InfoCard(
title: 'Frais d\'adhésion',
value: a.fraisAdhesion != null
? _currencyFormat.format(a.fraisAdhesion)
: '',
),
if (a.montantPaye != null && a.montantPaye! > 0)
_InfoCard(
title: 'Montant payé',
value: _currencyFormat.format(a.montantPaye!),
),
if (a.montantRestant > 0)
_InfoCard(
title: 'Montant restant',
value: _currencyFormat.format(a.montantRestant),
),
if (a.motifRejet != null && a.motifRejet!.isNotEmpty)
_InfoCard(title: 'Motif rejet', value: a.motifRejet!),
const SizedBox(height: 24),
_ActionsSection(adhesion: a, currencyFormat: _currencyFormat),
],
),
);
},
),
);
}
}
class _InfoCard extends StatelessWidget {
final String title;
final String value;
const _InfoCard({required this.title, required this.value});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
title,
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
),
Expanded(child: Text(value)),
],
),
),
);
}
}
class _ActionsSection extends StatelessWidget {
final AdhesionModel adhesion;
final NumberFormat currencyFormat;
const _ActionsSection({
required this.adhesion,
required this.currencyFormat,
});
@override
Widget build(BuildContext context) {
final bloc = context.read<AdhesionsBloc>();
if (adhesion.statut == 'EN_ATTENTE') {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Actions (admin)',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () {
if (adhesion.id == null) return;
bloc.add(ApprouverAdhesion(adhesion.id!));
},
icon: const Icon(Icons.check_circle),
label: const Text('Approuver'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () {
if (adhesion.id == null) return;
showDialog<void>(
context: context,
builder: (ctx) => BlocProvider.value(
value: bloc,
child: RejetAdhesionDialog(
adhesionId: adhesion.id!,
onRejected: () => Navigator.of(ctx).pop(),
),
),
);
},
icon: const Icon(Icons.cancel),
label: const Text('Rejeter'),
style: OutlinedButton.styleFrom(foregroundColor: Colors.red),
),
],
);
}
if (adhesion.estEnAttentePaiement && adhesion.id != null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Paiement',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () {
showDialog<void>(
context: context,
builder: (ctx) => BlocProvider.value(
value: bloc,
child: PaiementAdhesionDialog(
adhesionId: adhesion.id!,
montantRestant: adhesion.montantRestant,
onPaid: () => Navigator.of(ctx).pop(),
),
),
);
},
icon: const Icon(Icons.payment),
label: const Text('Enregistrer un paiement'),
),
],
);
}
return const SizedBox.shrink();
}
}

View File

@@ -0,0 +1,298 @@
/// Page liste des demandes d'adhésion
library adhesions_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../bloc/adhesions_bloc.dart';
import '../../data/models/adhesion_model.dart';
import 'adhesion_detail_page.dart';
import '../widgets/create_adhesion_dialog.dart';
class AdhesionsPage extends StatefulWidget {
const AdhesionsPage({super.key});
@override
State<AdhesionsPage> createState() => _AdhesionsPageState();
}
class _AdhesionsPageState extends State<AdhesionsPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA');
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
context.read<AdhesionsBloc>().add(const LoadAdhesions());
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _loadTab(int index) {
switch (index) {
case 0:
context.read<AdhesionsBloc>().add(const LoadAdhesions());
break;
case 1:
context.read<AdhesionsBloc>().add(const LoadAdhesionsEnAttente());
break;
case 2:
context.read<AdhesionsBloc>().add(const LoadAdhesionsByStatut('APPROUVEE'));
break;
case 3:
context.read<AdhesionsBloc>().add(const LoadAdhesionsByStatut('PAYEE'));
break;
}
}
@override
Widget build(BuildContext context) {
return BlocListener<AdhesionsBloc, AdhesionsState>(
listener: (context, state) {
if (state.status == AdhesionsStatus.error && state.message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message!),
backgroundColor: Colors.red,
action: SnackBarAction(
label: 'Réessayer',
textColor: Colors.white,
onPressed: () => _loadTab(_tabController.index),
),
),
);
}
},
child: Scaffold(
appBar: AppBar(
title: const Text('Demandes d\'adhésion'),
bottom: TabBar(
controller: _tabController,
onTap: _loadTab,
tabs: const [
Tab(text: 'Toutes', icon: Icon(Icons.list)),
Tab(text: 'En attente', icon: Icon(Icons.schedule)),
Tab(text: 'Approuvées', icon: Icon(Icons.check_circle_outline)),
Tab(text: 'Payées', icon: Icon(Icons.payment)),
],
),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _showCreateDialog(),
tooltip: 'Nouvelle demande',
),
],
),
body: TabBarView(
controller: _tabController,
children: [
_buildList(null),
_buildList('EN_ATTENTE'),
_buildList('APPROUVEE'), // tab 2 charge déjà par statut
_buildList('PAYEE'), // tab 3 charge déjà par statut
],
),
),
);
}
Widget _buildList(String? statutFilter) {
return BlocBuilder<AdhesionsBloc, AdhesionsState>(
buildWhen: (prev, curr) =>
prev.status != curr.status || prev.adhesions != curr.adhesions,
builder: (context, state) {
if (state.status == AdhesionsStatus.loading && state.adhesions.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
var list = state.adhesions;
if (statutFilter != null) {
list = list.where((a) => a.statut == statutFilter).toList();
}
if (list.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.assignment_outlined, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'Aucune demande d\'adhésion',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => _showCreateDialog(),
icon: const Icon(Icons.add),
label: const Text('Créer une demande'),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async => _loadTab(_tabController.index),
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: list.length,
itemBuilder: (context, index) {
final a = list[index];
return _AdhesionCard(
adhesion: a,
currencyFormat: _currencyFormat,
onTap: () => _openDetail(a),
);
},
),
);
},
);
}
void _openDetail(AdhesionModel a) {
if (a.id == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider.value(
value: context.read<AdhesionsBloc>(),
child: AdhesionDetailPage(adhesionId: a.id!),
),
),
).then((_) => _loadTab(_tabController.index));
}
void _showCreateDialog() {
showDialog<void>(
context: context,
builder: (context) => CreateAdhesionDialog(
onCreated: () {
Navigator.of(context).pop();
_loadTab(_tabController.index);
},
),
);
}
}
class _AdhesionCard extends StatelessWidget {
final AdhesionModel adhesion;
final NumberFormat currencyFormat;
final VoidCallback onTap;
const _AdhesionCard({
required this.adhesion,
required this.currencyFormat,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
adhesion.numeroReference ?? adhesion.id ?? '',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
_StatutChip(statut: adhesion.statut),
],
),
const SizedBox(height: 4),
Text(
adhesion.nomOrganisation ?? adhesion.organisationId ?? 'Organisation',
style: theme.textTheme.bodyMedium,
),
if (adhesion.nomMembreComplet.isNotEmpty)
Text(
adhesion.nomMembreComplet,
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
),
const SizedBox(height: 4),
Row(
children: [
Text(
adhesion.fraisAdhesion != null
? currencyFormat.format(adhesion.fraisAdhesion)
: '',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
if (adhesion.dateDemande != null) ...[
const Spacer(),
Text(
DateFormat('dd/MM/yyyy').format(adhesion.dateDemande!),
style: theme.textTheme.bodySmall,
),
],
],
),
],
),
),
),
);
}
}
class _StatutChip extends StatelessWidget {
final String? statut;
const _StatutChip({this.statut});
@override
Widget build(BuildContext context) {
Color color;
switch (statut) {
case 'EN_ATTENTE':
color = Colors.orange;
break;
case 'APPROUVEE':
case 'PAYEE':
color = Colors.green;
break;
case 'REJETEE':
color = Colors.red;
break;
case 'ANNULEE':
color = Colors.grey;
break;
case 'EN_PAIEMENT':
color = Colors.blue;
break;
default:
color = Colors.grey;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
statut ?? '',
style: TextStyle(fontSize: 12, color: color, fontWeight: FontWeight.w500),
),
);
}
}

View File

@@ -0,0 +1,26 @@
/// Wrapper BLoC pour la page des adhésions
library adhesions_page_wrapper;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../bloc/adhesions_bloc.dart';
import 'adhesions_page.dart';
final _getIt = GetIt.instance;
class AdhesionsPageWrapper extends StatelessWidget {
const AdhesionsPageWrapper({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<AdhesionsBloc>(
create: (context) {
final bloc = _getIt<AdhesionsBloc>();
bloc.add(const LoadAdhesions());
return bloc;
},
child: const AdhesionsPage(),
);
}
}

View File

@@ -0,0 +1,174 @@
/// Dialog de création d'une demande d'adhésion
library create_adhesion_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../bloc/adhesions_bloc.dart';
import '../../data/models/adhesion_model.dart';
import '../../../organizations/data/models/organization_model.dart';
import '../../../organizations/data/repositories/organization_repository.dart';
import '../../../members/data/services/membre_search_service.dart';
import '../../../members/data/models/membre_complete_model.dart';
class CreateAdhesionDialog extends StatefulWidget {
final VoidCallback onCreated;
const CreateAdhesionDialog({super.key, required this.onCreated});
@override
State<CreateAdhesionDialog> createState() => _CreateAdhesionDialogState();
}
class _CreateAdhesionDialogState extends State<CreateAdhesionDialog> {
final _fraisController = TextEditingController();
String? _membreId;
String? _organisationId;
bool _loading = false;
List<OrganizationModel> _organisations = [];
List<MembreCompletModel> _membres = [];
@override
void initState() {
super.initState();
_loadOrgs();
}
@override
void dispose() {
_fraisController.dispose();
super.dispose();
}
Future<void> _loadOrgs() async {
try {
final repo = GetIt.instance<OrganizationRepository>();
final list = await repo.getOrganizations(page: 0, size: 100);
if (mounted) setState(() => _organisations = list);
} catch (_) {
if (mounted) setState(() {});
}
}
Future<void> _searchMembres(String query) async {
if (query.length < 2) {
setState(() => _membres = []);
return;
}
try {
final service = GetIt.instance<MembreSearchService>();
final result = await service.quickSearch(query: query, size: 20);
if (mounted) setState(() => _membres = result.membres);
} catch (_) {
if (mounted) setState(() => _membres = []);
}
}
void _submit() {
if (_membreId == null || _organisationId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Veuillez sélectionner un membre et une organisation')),
);
return;
}
final frais = double.tryParse(_fraisController.text.replaceAll(',', '.'));
if (frais == null || frais <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Frais d\'adhésion invalides')),
);
return;
}
setState(() => _loading = true);
final adhesion = AdhesionModel(
membreId: _membreId,
organisationId: _organisationId,
fraisAdhesion: frais,
codeDevise: 'XOF',
dateDemande: DateTime.now(),
);
context.read<AdhesionsBloc>().add(CreateAdhesion(adhesion));
widget.onCreated();
if (mounted) {
setState(() => _loading = false);
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Nouvelle demande d\'adhésion'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
decoration: const InputDecoration(
labelText: 'Rechercher un membre (nom, prénom)',
border: OutlineInputBorder(),
),
onChanged: _searchMembres,
enabled: !_loading,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _membreId,
decoration: const InputDecoration(
labelText: 'Membre',
border: OutlineInputBorder(),
),
items: _membres
.map((m) => DropdownMenuItem<String>(
value: m.id,
child: Text('${m.prenom} ${m.nom}'),
))
.toList(),
onChanged: _loading ? null : (v) => setState(() => _membreId = v),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _organisationId,
decoration: const InputDecoration(
labelText: 'Organisation',
border: OutlineInputBorder(),
),
items: _organisations
.map((o) => DropdownMenuItem<String>(
value: o.id,
child: Text(o.nom),
))
.toList(),
onChanged: _loading ? null : (v) => setState(() => _organisationId = v),
),
const SizedBox(height: 16),
TextField(
controller: _fraisController,
decoration: const InputDecoration(
labelText: 'Frais d\'adhésion (FCFA)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
enabled: !_loading,
),
],
),
),
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Créer'),
),
],
);
}
}

View File

@@ -0,0 +1,140 @@
/// Dialog pour enregistrer un paiement sur une adhésion
library paiement_adhesion_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/adhesions_bloc.dart';
class PaiementAdhesionDialog extends StatefulWidget {
final String adhesionId;
final double montantRestant;
final VoidCallback onPaid;
const PaiementAdhesionDialog({
super.key,
required this.adhesionId,
required this.montantRestant,
required this.onPaid,
});
@override
State<PaiementAdhesionDialog> createState() => _PaiementAdhesionDialogState();
}
class _PaiementAdhesionDialogState extends State<PaiementAdhesionDialog> {
final _montantController = TextEditingController();
final _refController = TextEditingController();
String? _methode;
bool _loading = false;
@override
void initState() {
super.initState();
_montantController.text = widget.montantRestant.toStringAsFixed(0);
}
@override
void dispose() {
_montantController.dispose();
_refController.dispose();
super.dispose();
}
void _submit() {
final montant = double.tryParse(_montantController.text.replaceAll(',', '.'));
if (montant == null || montant <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Montant invalide')),
);
return;
}
if (montant > widget.montantRestant) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Le montant ne peut pas dépasser le restant dû')),
);
return;
}
setState(() => _loading = true);
context.read<AdhesionsBloc>().add(
EnregistrerPaiementAdhesion(
widget.adhesionId,
montantPaye: montant,
methodePaiement: _methode,
referencePaiement: _refController.text.trim().isEmpty
? null
: _refController.text.trim(),
),
);
widget.onPaid();
if (mounted) {
setState(() => _loading = false);
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Enregistrer un paiement'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Restant dû : ${widget.montantRestant.toStringAsFixed(0)} FCFA'),
const SizedBox(height: 16),
TextField(
controller: _montantController,
decoration: const InputDecoration(
labelText: 'Montant payé (FCFA)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
enabled: !_loading,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _methode,
decoration: const InputDecoration(
labelText: 'Méthode de paiement',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'ESPECES', child: Text('Espèces')),
DropdownMenuItem(value: 'VIREMENT', child: Text('Virement')),
DropdownMenuItem(value: 'WAVE_MONEY', child: Text('Wave Money')),
DropdownMenuItem(value: 'ORANGE_MONEY', child: Text('Orange Money')),
DropdownMenuItem(value: 'CHEQUE', child: Text('Chèque')),
],
onChanged: _loading ? null : (v) => setState(() => _methode = v),
),
const SizedBox(height: 12),
TextField(
controller: _refController,
decoration: const InputDecoration(
labelText: 'Référence (optionnel)',
border: OutlineInputBorder(),
),
enabled: !_loading,
),
],
),
),
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Enregistrer'),
),
],
);
}
}

View File

@@ -0,0 +1,82 @@
/// Dialog pour rejeter une adhésion (saisie du motif)
library rejet_adhesion_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/adhesions_bloc.dart';
class RejetAdhesionDialog extends StatefulWidget {
final String adhesionId;
final VoidCallback onRejected;
const RejetAdhesionDialog({
super.key,
required this.adhesionId,
required this.onRejected,
});
@override
State<RejetAdhesionDialog> createState() => _RejetAdhesionDialogState();
}
class _RejetAdhesionDialogState extends State<RejetAdhesionDialog> {
final _controller = TextEditingController();
bool _loading = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final motif = _controller.text.trim();
if (motif.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Veuillez saisir un motif de rejet')),
);
return;
}
setState(() => _loading = true);
context.read<AdhesionsBloc>().add(RejeterAdhesion(widget.adhesionId, motif));
widget.onRejected();
if (mounted) {
setState(() => _loading = false);
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Rejeter la demande'),
content: TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Motif du rejet',
hintText: 'Saisir le motif...',
border: OutlineInputBorder(),
),
maxLines: 3,
enabled: !_loading,
),
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: _loading ? null : _submit,
style: FilledButton.styleFrom(backgroundColor: Colors.red),
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Rejeter'),
),
],
);
}
}

View File

@@ -0,0 +1,89 @@
library admin_users_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/models/admin_user_model.dart';
import '../data/repositories/admin_user_repository.dart';
import 'admin_users_event.dart';
import 'admin_users_state.dart';
class AdminUsersBloc extends Bloc<AdminUsersEvent, AdminUsersState> {
final AdminUserRepository _repository;
AdminUsersBloc(this._repository) : super(AdminUsersInitial()) {
on<AdminUsersLoadRequested>(_onLoadRequested);
on<AdminUserDetailRequested>(_onDetailRequested);
on<AdminUserDetailWithRolesRequested>(_onDetailWithRolesRequested);
on<AdminUserRolesUpdateRequested>(_onRolesUpdateRequested);
on<AdminRolesLoadRequested>(_onRolesLoadRequested);
}
Future<void> _onLoadRequested(AdminUsersLoadRequested e, Emitter<AdminUsersState> emit) async {
emit(AdminUsersLoading());
try {
final result = await _repository.search(
page: e.page ?? 0,
size: e.size ?? 20,
search: e.search,
);
emit(AdminUsersLoaded(
users: result.users,
totalCount: result.totalCount,
currentPage: result.currentPage,
pageSize: result.pageSize,
totalPages: result.totalPages,
));
} catch (err) {
emit(AdminUsersError(err.toString()));
}
}
Future<void> _onDetailRequested(AdminUserDetailRequested e, Emitter<AdminUsersState> emit) async {
emit(AdminUsersLoading());
try {
final user = await _repository.getById(e.userId);
if (user == null) {
emit(AdminUsersError('Utilisateur non trouvé'));
return;
}
final roles = await _repository.getUserRoles(e.userId);
emit(AdminUserDetailLoaded(user: user, userRoles: roles));
} catch (err) {
emit(AdminUsersError(err.toString()));
}
}
Future<void> _onDetailWithRolesRequested(AdminUserDetailWithRolesRequested e, Emitter<AdminUsersState> emit) async {
emit(AdminUsersLoading());
try {
final user = await _repository.getById(e.userId);
if (user == null) {
emit(AdminUsersError('Utilisateur non trouvé'));
return;
}
final userRoles = await _repository.getUserRoles(e.userId);
final allRoles = await _repository.getRealmRoles();
emit(AdminUserDetailLoaded(user: user, userRoles: userRoles, allRoles: allRoles));
} catch (err) {
emit(AdminUsersError(err.toString()));
}
}
Future<void> _onRolesUpdateRequested(AdminUserRolesUpdateRequested e, Emitter<AdminUsersState> emit) async {
try {
await _repository.setUserRoles(e.userId, e.roleNames);
emit(AdminUserRolesUpdated());
add(AdminUserDetailWithRolesRequested(e.userId));
} catch (err) {
emit(AdminUsersError(err.toString()));
}
}
Future<void> _onRolesLoadRequested(AdminRolesLoadRequested e, Emitter<AdminUsersState> emit) async {
try {
final roles = await _repository.getRealmRoles();
emit(AdminRolesLoaded(roles));
} catch (err) {
emit(AdminUsersError(err.toString()));
}
}
}

View File

@@ -0,0 +1,29 @@
library admin_users_event;
abstract class AdminUsersEvent {}
class AdminUsersLoadRequested extends AdminUsersEvent {
final int page;
final int size;
final String? search;
AdminUsersLoadRequested({this.page = 0, this.size = 20, this.search});
}
class AdminUserDetailRequested extends AdminUsersEvent {
final String userId;
AdminUserDetailRequested(this.userId);
}
/// Charge détail utilisateur + liste complète des rôles (pour édition)
class AdminUserDetailWithRolesRequested extends AdminUsersEvent {
final String userId;
AdminUserDetailWithRolesRequested(this.userId);
}
class AdminUserRolesUpdateRequested extends AdminUsersEvent {
final String userId;
final List<String> roleNames;
AdminUserRolesUpdateRequested(this.userId, this.roleNames);
}
class AdminRolesLoadRequested extends AdminUsersEvent {}

View File

@@ -0,0 +1,47 @@
library admin_users_state;
import '../data/models/admin_user_model.dart';
abstract class AdminUsersState {}
class AdminUsersInitial extends AdminUsersState {}
class AdminUsersLoading extends AdminUsersState {}
class AdminUsersLoaded extends AdminUsersState {
final List<AdminUserModel> users;
final int totalCount;
final int currentPage;
final int pageSize;
final int totalPages;
AdminUsersLoaded({
required this.users,
required this.totalCount,
required this.currentPage,
required this.pageSize,
required this.totalPages,
});
}
class AdminUsersError extends AdminUsersState {
final String message;
AdminUsersError(this.message);
}
class AdminUserDetailLoaded extends AdminUsersState {
final AdminUserModel user;
final List<AdminRoleModel> userRoles;
final List<AdminRoleModel> allRoles;
AdminUserDetailLoaded({
required this.user,
required this.userRoles,
this.allRoles = const [],
});
}
class AdminRolesLoaded extends AdminUsersState {
final List<AdminRoleModel> roles;
AdminRolesLoaded(this.roles);
}
class AdminUserRolesUpdated extends AdminUsersState {}

View File

@@ -0,0 +1,66 @@
/// Modèle pour un utilisateur admin (Keycloak) - aligné sur l'API /api/admin/users
library admin_user_model;
class AdminUserModel {
final String id;
final String? username;
final String? email;
final String? prenom;
final String? nom;
final bool? enabled;
final List<String>? realmRoles;
AdminUserModel({
required this.id,
this.username,
this.email,
this.prenom,
this.nom,
this.enabled,
this.realmRoles,
});
String get displayName {
if (prenom != null && nom != null) return '$prenom $nom';
if (prenom != null) return prenom!;
if (nom != null) return nom!;
return username ?? email ?? id;
}
factory AdminUserModel.fromJson(Map<String, dynamic> json) {
final roles = json['realmRoles'] as List<dynamic>?;
return AdminUserModel(
id: json['id'] as String? ?? '',
username: json['username'] as String?,
email: json['email'] as String?,
prenom: json['prenom'] as String?,
nom: json['nom'] as String?,
enabled: json['enabled'] as bool?,
realmRoles: roles?.map((e) => e is Map ? (e['name'] as String?) ?? e.toString() : e.toString()).toList(),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'username': username,
'email': email,
'prenom': prenom,
'nom': nom,
'enabled': enabled,
'realmRoles': realmRoles,
};
}
class AdminRoleModel {
final String id;
final String name;
final String? description;
AdminRoleModel({required this.id, required this.name, this.description});
factory AdminRoleModel.fromJson(Map<String, dynamic> json) => AdminRoleModel(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
description: json['description'] as String?,
);
}

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