| @@ -0,0 +1,78 @@ | |||
| # 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 | |||
| .packages | |||
| .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 | |||
| /android/app/.gradle/ | |||
| /android/.gradle/ | |||
| /android/local.properties | |||
| /android/key.properties | |||
| # iOS/macOS | |||
| **/ios/**/*.mode1v3 | |||
| **/ios/**/*.mode2v3 | |||
| **/ios/**/*.moved-aside | |||
| **/ios/**/*.pbxuser | |||
| **/ios/**/*.perspectivev3 | |||
| **/ios/**/*sync/ | |||
| **/ios/**/.sconsign.dblite | |||
| **/ios/**/.tags* | |||
| **/ios/**/.vagrant/ | |||
| **/ios/**/DerivedData/ | |||
| **/ios/**/Icon? | |||
| **/ios/**/Icon?.* | |||
| **/ios/**/Info.plist | |||
| **/ios/**/Pods/ | |||
| **/ios/**/.symlinks/ | |||
| **/ios/**/Flutter/Flutter.framework | |||
| **/ios/**/Flutter/Flutter.podspec | |||
| **/ios/**/Flutter/GeneratedPluginRegistrant.* | |||
| **/ios/**/generated/ | |||
| **/ios/**/ephemeral/ | |||
| **/ios/Flutter/Flutter.podspec | |||
| **/ios/Flutter/Flutter.framework | |||
| # Coverage | |||
| coverage/ | |||
| # Exceptions to above rules. | |||
| !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages | |||
| @@ -0,0 +1,30 @@ | |||
| # This file tracks properties of this Flutter project. | |||
| # Used by Flutter tool to assess capabilities and perform upgrades etc. | |||
| # | |||
| # This file should be version controlled and should not be manually edited. | |||
| version: | |||
| revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" | |||
| channel: "stable" | |||
| project_type: app | |||
| # Tracks metadata for the flutter migrate command | |||
| migration: | |||
| platforms: | |||
| - platform: root | |||
| create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 | |||
| base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 | |||
| - platform: android | |||
| create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 | |||
| base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 | |||
| # User provided section | |||
| # List of Local paths (relative to this file) that should be | |||
| # ignored by the migrate tool. | |||
| # | |||
| # Files that are not part of the templates will be ignored by default. | |||
| unmanaged_files: | |||
| - 'lib/main.dart' | |||
| - 'ios/Runner.xcodeproj/project.pbxproj' | |||
| @@ -0,0 +1,116 @@ | |||
| # Social Content Creator - Flutter 3.38 | |||
| Application Flutter cross-platform (Android/iOS) pour créer et partager du contenu optimisé pour réseaux sociaux avec assistance IA. | |||
| ## 🚀 Prérequis | |||
| - **Flutter** : 3.38.0+ | |||
| - **Dart** : 3.10.0+ | |||
| - **Android SDK** : minSdk 26, targetSdk 35 | |||
| - **Xcode** : 15.0+ (pour iOS) | |||
| - **Kotlin** : 2.0.0+ | |||
| - **AGP** : 8.5.1+ | |||
| ## ⚙️ Installation | |||
| ### 1. Cloner et installer les dépendances | |||
| ```bash | |||
| unzip social_content_creator.zip | |||
| cd social_content_creator | |||
| flutter pub get | |||
| ``` | |||
| ### 2. Vérifier l'installation | |||
| ```bash | |||
| flutter doctor | |||
| flutter pub get | |||
| ``` | |||
| ### 3. Lancer en développement | |||
| ```bash | |||
| flutter run | |||
| ``` | |||
| ### 4. Build pour production | |||
| **Android APK :** | |||
| ```bash | |||
| flutter build apk --release | |||
| ``` | |||
| **Android App Bundle (Play Store) :** | |||
| ```bash | |||
| flutter build appbundle --release | |||
| ``` | |||
| **iOS :** | |||
| ```bash | |||
| flutter build ios --release | |||
| ``` | |||
| ## 📱 Fonctionnalités | |||
| ✅ Configuration multi-profils (métier, ton, style) | |||
| ✅ Import/capture d'images et vidéos | |||
| ✅ Amélioration IA avec 3 versions | |||
| ✅ Génération de texte personnalisé | |||
| ✅ Aperçu de post (Instagram/Facebook) | |||
| ✅ Partage sur réseaux sociaux (Instagram, Facebook, X, LinkedIn, TikTok) | |||
| ✅ Support Material Design 3 | |||
| ✅ Navigation nommée avec routes typées | |||
| ✅ Gestion d'état avec Provider/Riverpod | |||
| ## 🏗️ Architecture | |||
| ``` | |||
| lib/ | |||
| ├── core/ # Thème, constantes, utilitaires | |||
| ├── data/ # Modèles, services, repositories | |||
| ├── presentation/ # UI (écrans, widgets) | |||
| ├── routes/ # Navigation | |||
| └── main.dart # Entry point | |||
| ``` | |||
| ## 🛠️ Configuration API | |||
| Éditer `lib/data/services/api_service.dart` et ajouter vos endpoints : | |||
| ```dart | |||
| static const String baseUrl = 'https://votre-api.com'; | |||
| ``` | |||
| ## 🎨 Design System | |||
| - **Couleur primaire** : Indigo (#6366F1) | |||
| - **Couleur accent** : Amber (#F59E0B) | |||
| - **Police** : Inter (Google Fonts) | |||
| - **Border radius** : 12-16px | |||
| - **Spacing** : 8, 12, 16, 20, 24, 32px | |||
| ## 📦 Packages principaux | |||
| - `image_picker` : Sélection/capture de médias | |||
| - `dio` : Requêtes HTTP | |||
| - `share_plus` : Partage social | |||
| - `google_fonts` : Typographie | |||
| - `provider` : État de l'app | |||
| - `path_provider` : Accès fichiers | |||
| ## ✅ Versions modernes | |||
| - Flutter 3.38 (novembre 2025) | |||
| - Dart 3.10 | |||
| - Android Gradle Plugin 8.5.1 | |||
| - Kotlin 2.0.0 | |||
| - Material 3 | |||
| ## 📚 Documentation | |||
| Voir `Social_Content_Creator_Documentation.pdf` pour la documentation complète. | |||
| --- | |||
| **Version 1.0.0 - Novembre 2025** | |||
| @@ -0,0 +1,170 @@ | |||
| # This file configures the analyzer, which statically analyzes Dart code to | |||
| # check for errors, warnings, and lints. | |||
| include: package:flutter_lints/flutter.yaml | |||
| linter: | |||
| rules: | |||
| # Error Rules | |||
| - avoid_empty_else | |||
| - avoid_print | |||
| - avoid_relative_lib_imports | |||
| - avoid_returning_null_for_future | |||
| - cancel_subscriptions | |||
| - close_sinks | |||
| - comment_references | |||
| - control_flow_in_finally | |||
| - empty_statements | |||
| - hash_and_equals | |||
| - invariant_booleans | |||
| - iterable_contains_unrelated_type | |||
| - list_remove_unrelated_type | |||
| - literal_only_boolean_expressions | |||
| - no_adjacent_strings_in_list | |||
| - no_duplicate_case_values | |||
| - prefer_void_to_null | |||
| - throw_in_finally | |||
| - unnecessary_statements | |||
| - unrelated_type_equality_checks | |||
| # Style Rules | |||
| - always_declare_return_types | |||
| - always_put_control_body_on_new_line | |||
| - always_put_required_named_parameters_first | |||
| - annotate_overrides | |||
| - avoid_bool_literals_in_conditional_expressions | |||
| - avoid_catches_without_on_clauses | |||
| - avoid_catching_errors | |||
| - avoid_double_and_int_checks | |||
| - avoid_field_initializers_in_const_classes | |||
| - avoid_function_literals_in_foreach_calls | |||
| - avoid_init_to_null | |||
| - avoid_null_checks_in_equality_operators | |||
| - avoid_positional_boolean_parameters | |||
| - avoid_private_typedef_functions | |||
| - avoid_renaming_method_parameters | |||
| - avoid_returning_null | |||
| - avoid_returning_null_for_async | |||
| - avoid_returning_this | |||
| - avoid_setters_without_getters | |||
| - avoid_shadowing_type_parameters | |||
| - avoid_slow_async_io | |||
| - avoid_types_as_parameter_names | |||
| - avoid_types_on_closure_parameters | |||
| - avoid_unnecessary_containers | |||
| - avoid_unnecessary_setstate | |||
| - await_only_futures | |||
| - camel_case_extensions | |||
| - camel_case_types | |||
| - cascade_invocations | |||
| - cast_nullable_to_non_nullable | |||
| - constant_identifier_names | |||
| - curly_braces_in_flow_control_structures | |||
| - directives_ordering | |||
| - empty_catches | |||
| - empty_constructor_bodies | |||
| - eol_at_end_of_file | |||
| - file_names | |||
| - implementation_imports | |||
| - leading_newlines_in_multiline_strings | |||
| - library_names | |||
| - library_prefixes | |||
| - library_private_types_in_public_api | |||
| - lines_longer_than_80_chars | |||
| - no_leading_underscores_for_library_prefixes | |||
| - null_closures | |||
| - omit_local_variable_types | |||
| - one_member_abstracts | |||
| - only_throw_errors | |||
| - overridden_fields | |||
| - package_api_docs | |||
| - package_names | |||
| - package_prefixed_library_names | |||
| - parameter_assignments | |||
| - prefer_adjacent_string_concatenation | |||
| - prefer_asserts_in_initializer_lists | |||
| - prefer_asserts_with_message | |||
| - prefer_collection_literals | |||
| - prefer_conditional_assignment | |||
| - prefer_const_constructors | |||
| - prefer_const_constructors_in_immutables | |||
| - prefer_const_declarations | |||
| - prefer_const_literals_to_create_immutables | |||
| - prefer_constructors_over_static_methods | |||
| - prefer_contains | |||
| - prefer_equal_for_default_values | |||
| - prefer_expression_function_bodies | |||
| - prefer_final_fields | |||
| - prefer_final_in_for_each | |||
| - prefer_final_locals | |||
| - prefer_for_elements_to_map_fromIterable | |||
| - prefer_foreach | |||
| - prefer_function_declarations_over_variables | |||
| - prefer_generic_function_type_aliases | |||
| - prefer_if_elements_to_conditional_expressions | |||
| - prefer_if_null_to_conditional_expressions | |||
| - prefer_if_on_single_line_statements | |||
| - prefer_inline_and_then | |||
| - prefer_inlined_adds | |||
| - prefer_int_literals | |||
| - prefer_interpolation_to_compose_strings | |||
| - prefer_is_empty | |||
| - prefer_is_not_empty | |||
| - prefer_is_not_operator | |||
| - prefer_is_operator | |||
| - prefer_iterable_whereType | |||
| - prefer_null_aware_operators | |||
| - prefer_null_coalescing_operator | |||
| - prefer_null_coalescing_operators | |||
| - prefer_on_platform_annotation | |||
| - prefer_relative_import_paths | |||
| - prefer_relative_imports | |||
| - prefer_set_literal | |||
| - prefer_single_quotes | |||
| - provide_deprecation_message | |||
| - recursive_getters | |||
| - sized_box_for_spacer | |||
| - sized_box_shrink_expand | |||
| - slash_for_doc_comments | |||
| - sort_child_properties_last | |||
| - sort_constructors_first | |||
| - sort_pub_dependencies | |||
| - sort_unnamed_constructors_first | |||
| - tighten_type_of_initializing_formals | |||
| - type_annotate_public_apis | |||
| - type_init_formals | |||
| - unawaited_futures | |||
| - unnecessary_await_in_return | |||
| - unnecessary_brace_in_string_interps | |||
| - unnecessary_const | |||
| - unnecessary_constructor_name | |||
| - unnecessary_getters_setters | |||
| - unnecessary_lambdas | |||
| - unnecessary_null_aware_assignments | |||
| - unnecessary_null_in_if_null_operators | |||
| - unnecessary_null_checks | |||
| - unnecessary_null_in_return_type_checks | |||
| - unnecessary_nullable_for_final_variable_declarations | |||
| - unnecessary_parenthesis | |||
| - unnecessary_raw_strings | |||
| - unnecessary_string_escapes | |||
| - unnecessary_string_interpolations | |||
| - unnecessary_to_list_in_spreads | |||
| - unsafe_html | |||
| - use_build_context_synchronously | |||
| - use_full_hex_values_for_flutter_colors | |||
| - use_function_type_syntax_for_parameters | |||
| - use_if_null_to_convert_nulls | |||
| - use_is_even_rather_than_modulo | |||
| - use_key_in_widget_constructors | |||
| - use_late_for_private_fields_and_variables | |||
| - use_named_constants | |||
| - use_raw_strings | |||
| - use_rethrow_when_possible | |||
| - use_setters_to_change_properties | |||
| - use_string_buffers | |||
| - use_test_throws_matchers | |||
| - use_to_close_resource | |||
| - use_trailing_commas | |||
| - void_checks | |||
| - wildcard_import_warning | |||
| @@ -0,0 +1,14 @@ | |||
| gradle-wrapper.jar | |||
| /.gradle | |||
| /captures/ | |||
| /gradlew | |||
| /gradlew.bat | |||
| /local.properties | |||
| GeneratedPluginRegistrant.java | |||
| .cxx/ | |||
| # Remember to never publicly share your keystore. | |||
| # See https://flutter.dev/to/reference-keystore | |||
| key.properties | |||
| **/*.keystore | |||
| **/*.jks | |||
| @@ -0,0 +1,54 @@ | |||
| plugins { | |||
| id("com.android.application") | |||
| id("kotlin-android") | |||
| id("dev.flutter.flutter-gradle-plugin") | |||
| } | |||
| android { | |||
| namespace = "com.example.social_content_creator" | |||
| compileSdk = 36 | |||
| defaultConfig { | |||
| applicationId = "com.example.social_content_creator" | |||
| minSdk = flutter.minSdkVersion | |||
| targetSdk = 35 | |||
| versionCode = 1 | |||
| versionName = "1.0" | |||
| } | |||
| buildTypes { | |||
| release { | |||
| isMinifyEnabled = false | |||
| isShrinkResources = false | |||
| } | |||
| } | |||
| /* flavorDimensions.add("version") | |||
| productFlavors { | |||
| create("free") { | |||
| dimension = "version" | |||
| } | |||
| }*/ | |||
| compileOptions { | |||
| sourceCompatibility = JavaVersion.VERSION_11 | |||
| targetCompatibility = JavaVersion.VERSION_11 | |||
| } | |||
| kotlinOptions { | |||
| jvmTarget = JavaVersion.VERSION_11.toString() | |||
| } | |||
| } | |||
| dependencies { | |||
| implementation("androidx.core:core-splashscreen:1.0.1") | |||
| implementation("androidx.appcompat:appcompat:1.7.0") | |||
| implementation("androidx.activity:activity-ktx:1.9.1") | |||
| implementation("com.google.android.material:material:1.12.0") | |||
| testImplementation("junit:junit:4.13.2") | |||
| androidTestImplementation("androidx.test:runner:1.6.2") | |||
| androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| # Flutter wrapper | |||
| -keep class io.flutter.app.** { *; } | |||
| -keep class io.flutter.plugin.** { *; } | |||
| -keep class io.flutter.util.** { *; } | |||
| -keep class io.flutter.view.** { *; } | |||
| -keep class io.flutter.** { *; } | |||
| -keep class io.flutter.plugins.** { *; } | |||
| # Firebase (if used) | |||
| -keep class com.google.firebase.** { *; } | |||
| # Preserve line numbers for debugging crashes | |||
| -keepattributes SourceFile,LineNumberTable | |||
| -renamesourcefileattribute SourceFile | |||
| @@ -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> | |||
| @@ -0,0 +1,53 @@ | |||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" | |||
| package="com.example.social_content_creator"> | |||
| <!-- Permissions for camera, gallery, and file access --> | |||
| <uses-permission android:name="android.permission.INTERNET" /> | |||
| <uses-permission android:name="android.permission.CAMERA" /> | |||
| <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> | |||
| <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> | |||
| <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> | |||
| <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> | |||
| <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> | |||
| <application | |||
| android:label="Social Content Creator" | |||
| android:name="${applicationName}" | |||
| android:icon="@mipmap/ic_launcher" | |||
| android:usesCleartextTraffic="false"> | |||
| <activity | |||
| android:name=".MainActivity" | |||
| android:exported="true" | |||
| android:launchMode="singleTop" | |||
| android:theme="@style/LaunchTheme" | |||
| android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" | |||
| android:hardwareAccelerated="true" | |||
| android:windowSoftInputMode="adjustResize"> | |||
| <meta-data | |||
| android:name="io.flutter.embedding.android.NormalTheme" | |||
| android:resource="@style/NormalTheme" /> | |||
| <intent-filter> | |||
| <action android:name="android.intent.action.MAIN" /> | |||
| <category android:name="android.intent.category.LAUNCHER" /> | |||
| </intent-filter> | |||
| </activity> | |||
| <meta-data | |||
| android:name="flutterEmbedding" | |||
| android:value="2" /> | |||
| <!-- FileProvider for sharing --> | |||
| <provider | |||
| android:name="androidx.core.content.FileProvider" | |||
| android:authorities="${applicationId}.fileprovider" | |||
| android:exported="false" | |||
| android:grantUriPermissions="true"> | |||
| <meta-data | |||
| android:name="android.support.FILE_PROVIDER_PATHS" | |||
| android:resource="@xml/file_paths" /> | |||
| </provider> | |||
| </application> | |||
| </manifest> | |||
| @@ -0,0 +1,12 @@ | |||
| package com.example.social_content_creator | |||
| import io.flutter.embedding.android.FlutterActivity | |||
| import io.flutter.embedding.engine.FlutterEngine | |||
| import io.flutter.plugins.GeneratedPluginRegistrant | |||
| class MainActivity: FlutterActivity() { | |||
| override fun configureFlutterEngine(flutterEngine: FlutterEngine) { | |||
| GeneratedPluginRegistrant.registerWith(flutterEngine) | |||
| super.configureFlutterEngine(flutterEngine) | |||
| } | |||
| } | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -0,0 +1,6 @@ | |||
| <?xml version="1.0" encoding="utf-8"?> | |||
| <paths xmlns:android="http://schemas.android.com/apk/res/android"> | |||
| <external-path name="external_files" path="."/> | |||
| <cache-path name="cache" path="." /> | |||
| <files-path name="files" path="." /> | |||
| </paths> | |||
| @@ -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> | |||
| @@ -0,0 +1,20 @@ | |||
| allprojects { | |||
| repositories { | |||
| google() | |||
| mavenCentral() | |||
| } | |||
| } | |||
| rootProject.layout.buildDirectory.set(file("../build")) | |||
| subprojects { | |||
| project.layout.buildDirectory.set(file("${rootProject.layout.buildDirectory.get()}/${project.name}")) | |||
| } | |||
| subprojects { | |||
| project.evaluationDependsOn(":app") | |||
| } | |||
| tasks.register<Delete>("clean") { | |||
| delete(rootProject.layout.buildDirectory) | |||
| } | |||
| @@ -0,0 +1,8 @@ | |||
| properties | |||
| org.gradle.java.home=C:\\Program Files\\Android\\Android Studio\\jbr | |||
| org.gradle.jvmargs=-Xmx4096m | |||
| org.gradle.parallel=true | |||
| org.gradle.workers.max=4 | |||
| android.useAndroidX=true | |||
| android.enableJetifier=true | |||
| @@ -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 | |||
| @@ -0,0 +1,37 @@ | |||
| pluginManagement { | |||
| val flutterSdkPath = run { | |||
| val properties = java.util.Properties() | |||
| val localPropertiesFile = java.io.File(rootProject.projectDir, "local.properties") | |||
| if (localPropertiesFile.exists()) { | |||
| java.io.FileInputStream(localPropertiesFile).use { stream -> | |||
| properties.load(stream) | |||
| } | |||
| } | |||
| val flutterSdk = properties.getProperty("flutter.sdk") | |||
| check(flutterSdk != null) { | |||
| """ | |||
| Flutter SDK not found. | |||
| Define location with flutter.sdk in the local.properties file. | |||
| """.trimIndent() | |||
| } | |||
| flutterSdk | |||
| } | |||
| 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.6.0" apply false | |||
| id("org.jetbrains.kotlin.android") version "2.1.0" apply false | |||
| } | |||
| include(":app") | |||
| @@ -0,0 +1,3 @@ | |||
| description: This file stores settings for Dart & Flutter DevTools. | |||
| documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states | |||
| extensions: | |||
| @@ -0,0 +1,3 @@ | |||
| org.gradle.jvmargs=-Xmx4096m | |||
| android.useAndroidX=true | |||
| android.enableJetifier=true | |||
| @@ -0,0 +1,14 @@ | |||
| // This is a generated file; do not edit or check into version control. | |||
| FLUTTER_ROOT=C:\src\flutter | |||
| FLUTTER_APPLICATION_PATH=C:\Users\yann\AndroidStudioProjects\social_content_creator | |||
| COCOAPODS_PARALLEL_CODE_SIGN=true | |||
| FLUTTER_TARGET=lib\main.dart | |||
| FLUTTER_BUILD_DIR=build | |||
| FLUTTER_BUILD_NAME=1.0.0 | |||
| FLUTTER_BUILD_NUMBER=1 | |||
| EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 | |||
| EXCLUDED_ARCHS[sdk=iphoneos*]=armv7 | |||
| DART_OBFUSCATION=false | |||
| TRACK_WIDGET_CREATION=true | |||
| TREE_SHAKE_ICONS=false | |||
| PACKAGE_CONFIG=.dart_tool/package_config.json | |||
| @@ -0,0 +1,13 @@ | |||
| #!/bin/sh | |||
| # This is a generated file; do not edit or check into version control. | |||
| export "FLUTTER_ROOT=C:\src\flutter" | |||
| export "FLUTTER_APPLICATION_PATH=C:\Users\yann\AndroidStudioProjects\social_content_creator" | |||
| export "COCOAPODS_PARALLEL_CODE_SIGN=true" | |||
| export "FLUTTER_TARGET=lib\main.dart" | |||
| export "FLUTTER_BUILD_DIR=build" | |||
| export "FLUTTER_BUILD_NAME=1.0.0" | |||
| export "FLUTTER_BUILD_NUMBER=1" | |||
| export "DART_OBFUSCATION=false" | |||
| export "TRACK_WIDGET_CREATION=true" | |||
| export "TREE_SHAKE_ICONS=false" | |||
| export "PACKAGE_CONFIG=.dart_tool/package_config.json" | |||
| @@ -0,0 +1,48 @@ | |||
| # Podfile for Flutter 3.38 | |||
| # CocoaPods analytics sends network stats synchronously affecting flutter build latency. | |||
| ENV['COCOAPODS_DISABLE_STATS'] = 'true' | |||
| project 'Runner', { | |||
| 'Debug' => :debug, | |||
| 'Profile' => :release, | |||
| 'Release' => :release, | |||
| } | |||
| def flutter_root | |||
| generated_xcode_build_settings_path = File.expand_path(File.join( | |||
| File.dirname(__FILE__), 'Flutter', 'Generated.xcconfig'), __FILE__) | |||
| unless File.exist?(generated_xcode_build_settings_path) | |||
| raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" | |||
| end | |||
| File.foreach(generated_xcode_build_settings_path) do |line| | |||
| matches = line.match(/FLUTTER_ROOT\=(.*)/) | |||
| return matches[1].strip if matches | |||
| end | |||
| raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" | |||
| end | |||
| require File.expand_path(File.join(packages_path, 'flutter_tools', 'bin', 'podhelper'), flutter_root) | |||
| flutter_ios_podfile_setup | |||
| target 'Runner' do | |||
| use_frameworks! | |||
| use_modular_headers! | |||
| flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) | |||
| end | |||
| post_install do |installer| | |||
| installer.pods_project.targets.each do |target| | |||
| flutter_additional_ios_build_settings(target) | |||
| target.build_configurations.each do |config| | |||
| config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ | |||
| '$(inherited)', | |||
| 'PERMISSION_CAMERA=1', | |||
| 'PERMISSION_PHOTOS=1', | |||
| ] | |||
| end | |||
| end | |||
| end | |||
| @@ -0,0 +1,19 @@ | |||
| // | |||
| // Generated file. Do not edit. | |||
| // | |||
| // clang-format off | |||
| #ifndef GeneratedPluginRegistrant_h | |||
| #define GeneratedPluginRegistrant_h | |||
| #import <Flutter/Flutter.h> | |||
| NS_ASSUME_NONNULL_BEGIN | |||
| @interface GeneratedPluginRegistrant : NSObject | |||
| + (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry; | |||
| @end | |||
| NS_ASSUME_NONNULL_END | |||
| #endif /* GeneratedPluginRegistrant_h */ | |||
| @@ -0,0 +1,63 @@ | |||
| // | |||
| // Generated file. Do not edit. | |||
| // | |||
| // clang-format off | |||
| #import "GeneratedPluginRegistrant.h" | |||
| #if __has_include(<flutter_facebook_auth/FlutterFacebookAuthPlugin.h>) | |||
| #import <flutter_facebook_auth/FlutterFacebookAuthPlugin.h> | |||
| #else | |||
| @import flutter_facebook_auth; | |||
| #endif | |||
| #if __has_include(<flutter_secure_storage/FlutterSecureStoragePlugin.h>) | |||
| #import <flutter_secure_storage/FlutterSecureStoragePlugin.h> | |||
| #else | |||
| @import flutter_secure_storage; | |||
| #endif | |||
| #if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>) | |||
| #import <image_picker_ios/FLTImagePickerPlugin.h> | |||
| #else | |||
| @import image_picker_ios; | |||
| #endif | |||
| #if __has_include(<path_provider_foundation/PathProviderPlugin.h>) | |||
| #import <path_provider_foundation/PathProviderPlugin.h> | |||
| #else | |||
| @import path_provider_foundation; | |||
| #endif | |||
| #if __has_include(<share_plus/FPPSharePlusPlugin.h>) | |||
| #import <share_plus/FPPSharePlusPlugin.h> | |||
| #else | |||
| @import share_plus; | |||
| #endif | |||
| #if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>) | |||
| #import <shared_preferences_foundation/SharedPreferencesPlugin.h> | |||
| #else | |||
| @import shared_preferences_foundation; | |||
| #endif | |||
| #if __has_include(<sqflite_darwin/SqflitePlugin.h>) | |||
| #import <sqflite_darwin/SqflitePlugin.h> | |||
| #else | |||
| @import sqflite_darwin; | |||
| #endif | |||
| @implementation GeneratedPluginRegistrant | |||
| + (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry { | |||
| [FlutterFacebookAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterFacebookAuthPlugin"]]; | |||
| [FlutterSecureStoragePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStoragePlugin"]]; | |||
| [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; | |||
| [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; | |||
| [FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]]; | |||
| [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; | |||
| [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; | |||
| } | |||
| @end | |||
| @@ -0,0 +1,101 @@ | |||
| import 'package:flutter/material.dart'; | |||
| import 'package:google_fonts/google_fonts.dart'; | |||
| import 'colors.dart'; | |||
| class AppTheme { | |||
| static ThemeData get lightTheme { | |||
| return ThemeData( | |||
| useMaterial3: true, | |||
| colorScheme: ColorScheme.fromSeed( | |||
| seedColor: AppColors.primary, | |||
| brightness: Brightness.light, | |||
| ), | |||
| scaffoldBackgroundColor: AppColors.background, | |||
| textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme), | |||
| appBarTheme: AppBarTheme( | |||
| backgroundColor: Colors.white, | |||
| elevation: 0, | |||
| centerTitle: true, | |||
| scrolledUnderElevation: 0, | |||
| titleTextStyle: GoogleFonts.inter( | |||
| fontSize: 20, | |||
| fontWeight: FontWeight.w600, | |||
| color: AppColors.textPrimary, | |||
| ), | |||
| iconTheme: const IconThemeData(color: AppColors.textPrimary), | |||
| ), | |||
| elevatedButtonTheme: ElevatedButtonThemeData( | |||
| style: ElevatedButton.styleFrom( | |||
| backgroundColor: AppColors.primary, | |||
| foregroundColor: Colors.white, | |||
| padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), | |||
| shape: RoundedRectangleBorder( | |||
| borderRadius: BorderRadius.circular(12), | |||
| ), | |||
| elevation: 0, | |||
| textStyle: GoogleFonts.inter( | |||
| fontSize: 16, | |||
| fontWeight: FontWeight.w600, | |||
| ), | |||
| ), | |||
| ), | |||
| outlinedButtonTheme: OutlinedButtonThemeData( | |||
| style: OutlinedButton.styleFrom( | |||
| padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), | |||
| shape: RoundedRectangleBorder( | |||
| borderRadius: BorderRadius.circular(12), | |||
| ), | |||
| ), | |||
| ), | |||
| inputDecorationTheme: InputDecorationTheme( | |||
| filled: true, | |||
| fillColor: Colors.grey[50], | |||
| border: OutlineInputBorder( | |||
| borderRadius: BorderRadius.circular(12), | |||
| borderSide: BorderSide(color: Colors.grey[300]!), | |||
| ), | |||
| enabledBorder: OutlineInputBorder( | |||
| borderRadius: BorderRadius.circular(12), | |||
| borderSide: BorderSide(color: Colors.grey[300]!), | |||
| ), | |||
| focusedBorder: OutlineInputBorder( | |||
| borderRadius: BorderRadius.circular(12), | |||
| borderSide: const BorderSide(color: AppColors.primary, width: 2), | |||
| ), | |||
| contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), | |||
| ), | |||
| cardTheme: CardThemeData( | |||
| elevation: 0, | |||
| shape: RoundedRectangleBorder( | |||
| borderRadius: BorderRadius.circular(16), | |||
| side: BorderSide(color: Colors.grey[200]!), | |||
| ), | |||
| color: Colors.white, | |||
| ), | |||
| chipTheme: ChipThemeData( | |||
| padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), | |||
| labelPadding: const EdgeInsets.symmetric(horizontal: 8), | |||
| shape: RoundedRectangleBorder( | |||
| borderRadius: BorderRadius.circular(24), | |||
| ), | |||
| ), | |||
| ); | |||
| } | |||
| static ThemeData get darkTheme { | |||
| return ThemeData( | |||
| useMaterial3: true, | |||
| colorScheme: ColorScheme.fromSeed( | |||
| seedColor: AppColors.primary, | |||
| brightness: Brightness.dark, | |||
| ), | |||
| scaffoldBackgroundColor: const Color(0xFF121212), | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,42 @@ | |||
| import 'package:flutter/material.dart'; | |||
| /// Palettes de couleurs pour l'application Social Content Creator | |||
| abstract final class AppColors { | |||
| // Primary Colors | |||
| static const Color primary = Color(0xFF6366F1); // Indigo | |||
| static const Color primaryDark = Color(0xFF4F46E5); | |||
| static const Color primaryLight = Color(0xFF818CF8); | |||
| // Accent Colors | |||
| static const Color accent = Color(0xFFF59E0B); // Amber | |||
| static const Color accentLight = Color(0xFFFBBF24); | |||
| // Background Colors | |||
| static const Color background = Color(0xFFF9FAFB); | |||
| static const Color cardBackground = Colors.white; | |||
| static const Color surface = Color(0xFFF3F4F6); | |||
| // Text Colors | |||
| static const Color textPrimary = Color(0xFF111827); | |||
| static const Color textSecondary = Color(0xFF6B7280); | |||
| static const Color textTertiary = Color(0xFF9CA3AF); | |||
| // Tone Colors | |||
| static const Color toneFun = Color(0xFFEC4899); // Pink | |||
| static const Color toneSerious = Color(0xFF3B82F6); // Blue | |||
| static const Color toneNormal = Color(0xFF10B981); // Green | |||
| static const Color toneOleOle = Color(0xFFF59E0B); // Orange | |||
| // Status Colors | |||
| static const Color success = Color(0xFF10B981); | |||
| static const Color error = Color(0xFFEF4444); | |||
| static const Color warning = Color(0xFFF59E0B); | |||
| static const Color info = Color(0xFF3B82F6); | |||
| // Social Media Colors | |||
| static const Color instagram = Color(0xFFE4405F); | |||
| static const Color facebook = Color(0xFF1877F2); | |||
| static const Color twitter = Color(0xFF1DA1F2); | |||
| static const Color linkedin = Color(0xFF0A66C2); | |||
| static const Color tiktok = Color(0xFF000000); | |||
| } | |||
| @@ -0,0 +1,94 @@ | |||
| import 'dart:io'; | |||
| /// Enum pour le type de média | |||
| enum MediaType { | |||
| image, | |||
| video; | |||
| String get label => name.toUpperCase(); | |||
| } | |||
| /// Enum pour les plateformes sociales | |||
| enum SocialPlatform { | |||
| instagram('Instagram', '📸', 0xFFE4405F), | |||
| facebook('Facebook', '👍', 0xFF1877F2), | |||
| tiktok('TikTok', '🎵', 0xFF000000), | |||
| twitter('X', '𝕏', 0xFF1DA1F2), | |||
| linkedin('LinkedIn', '💼', 0xFF0A66C2); | |||
| final String displayName; | |||
| final String emoji; | |||
| final int colorValue; | |||
| const SocialPlatform(this.displayName, this.emoji, this.colorValue); | |||
| } | |||
| /// Modèle pour un post de contenu (Dart 3.10) | |||
| final class ContentPost { | |||
| /// Modèle pour un post de contenu (Dart 3.10)final class ContentPost { | |||
| final File? originalMedia; | |||
| final File? enhancedMedia; | |||
| final MediaType mediaType; | |||
| final String? generatedText; | |||
| final bool includeEmojis; | |||
| final bool includeCommercialInfo; | |||
| final Set<SocialPlatform> selectedPlatforms; | |||
| final DateTime createdAt; | |||
| // CORRECTION 1 : Le mot-clé 'const' est retiré du constructeur. | |||
| ContentPost({ | |||
| this.originalMedia, | |||
| this.enhancedMedia, | |||
| required this.mediaType, | |||
| this.generatedText, | |||
| this.includeEmojis = true, | |||
| this.includeCommercialInfo = false, | |||
| this.selectedPlatforms = const {}, | |||
| DateTime? createdAt, | |||
| }) : createdAt = createdAt ?? DateTime.now(); // Cette ligne est maintenant valide. | |||
| /// Utiliser records pour retourner plusieurs valeurs | |||
| (File?, String?) getMediaAndText() => (enhancedMedia ?? originalMedia, generatedText); | |||
| ContentPost copyWith({ | |||
| File? originalMedia, | |||
| File? enhancedMedia, | |||
| MediaType? mediaType, | |||
| String? generatedText, | |||
| bool? includeEmojis, | |||
| bool? includeCommercialInfo, | |||
| Set<SocialPlatform>? selectedPlatforms, | |||
| // Note : On n'ajoute pas 'createdAt' dans les paramètres ici, | |||
| // car on veut généralement conserver la date de création originale lors d'une copie. | |||
| }) { | |||
| return ContentPost( | |||
| originalMedia: originalMedia ?? this.originalMedia, | |||
| enhancedMedia: enhancedMedia ?? this.enhancedMedia, | |||
| mediaType: mediaType ?? this.mediaType, | |||
| generatedText: generatedText ?? this.generatedText, | |||
| includeEmojis: includeEmojis ?? this.includeEmojis, | |||
| includeCommercialInfo: includeCommercialInfo ?? this.includeCommercialInfo, | |||
| selectedPlatforms: selectedPlatforms ?? this.selectedPlatforms, | |||
| // CORRECTION 2 : On passe explicitement la date de création de l'objet actuel ('this'). | |||
| createdAt: this.createdAt, | |||
| ); | |||
| } | |||
| @override | |||
| String toString() => | |||
| 'ContentPost(type: ${mediaType.label}, platforms: ${selectedPlatforms.length})'; | |||
| } | |||
| /// Record pour les résultats d'API (Dart 3.10) | |||
| typedef EnhancementResult = ({ | |||
| List<File> versions, | |||
| DateTime processedAt, | |||
| String? error, | |||
| }); | |||
| typedef TextGenerationResult = ({ | |||
| List<String> proposals, | |||
| DateTime generatedAt, | |||
| String? error, | |||
| }); | |||
| @@ -0,0 +1,77 @@ | |||
| import 'package:flutter/foundation.dart'; | |||
| /// Enum pour les tons de message (Dart 3.10 with enhanced members) | |||
| enum MessageTone { | |||
| fun('Fun', '😄'), | |||
| serious('Sérieux', '💼'), | |||
| normal('Normal', '✨'), | |||
| oleOle('Olé Olé', '🔥'); | |||
| final String displayName; | |||
| final String emoji; | |||
| const MessageTone(this.displayName, this.emoji); | |||
| } | |||
| /// Enum pour le style de texte | |||
| enum TextStyleEnum { | |||
| classic('Classique'), | |||
| modern('Moderne'), | |||
| playful('Ludique'); | |||
| final String displayName; | |||
| const TextStyleEnum(this.displayName); | |||
| } | |||
| /// Modèle de profil utilisateur (Dart 3.10) | |||
| final class UserProfile { | |||
| final String profession; | |||
| final MessageTone tone; | |||
| final TextStyleEnum textStyle; | |||
| const UserProfile({ | |||
| required this.profession, | |||
| required this.tone, | |||
| required this.textStyle, | |||
| }); | |||
| /// Records pour extraction facile des données | |||
| (String, MessageTone, TextStyleEnum) toRecord() => | |||
| (profession, tone, textStyle); | |||
| Map<String, dynamic> toJson() => <String, dynamic>{ | |||
| 'profession': profession, | |||
| 'tone': tone.name, | |||
| 'textStyle': textStyle.name, | |||
| }; | |||
| factory UserProfile.fromJson(Map<String, dynamic> json) { | |||
| return UserProfile( | |||
| profession: json['profession'] as String? ?? '', | |||
| tone: MessageTone.values.firstWhere( | |||
| (e) => e.name == json['tone'], | |||
| orElse: () => MessageTone.normal, | |||
| ), | |||
| textStyle: TextStyleEnum.values.firstWhere( | |||
| (e) => e.name == json['textStyle'], | |||
| orElse: () => TextStyleEnum.classic, | |||
| ), | |||
| ); | |||
| } | |||
| UserProfile copyWith({ | |||
| String? profession, | |||
| MessageTone? tone, | |||
| TextStyleEnum? textStyle, | |||
| }) { | |||
| return UserProfile( | |||
| profession: profession ?? this.profession, | |||
| tone: tone ?? this.tone, | |||
| textStyle: textStyle ?? this.textStyle, | |||
| ); | |||
| } | |||
| @override | |||
| String toString() => | |||
| 'UserProfile(profession: $profession, tone: ${tone.displayName}, style: ${textStyle.displayName})'; | |||
| } | |||
| @@ -0,0 +1,175 @@ | |||
| import 'dart:io'; | |||
| import 'dart:typed_data'; | |||
| import 'package:dio/dio.dart'; | |||
| import 'package:path/path.dart' as path; | |||
| import '../models/content_post.dart'; | |||
| /// Service API pour l'amélioration IA et la génération de texte | |||
| class ApiService { | |||
| late final Dio _dio; | |||
| static const String baseUrl = 'https://api.your-ai-provider.com'; | |||
| static const String aiEnhancementEndpoint = '/api/v1/enhance-image'; | |||
| static const String textGenerationEndpoint = '/api/v1/generate-text'; | |||
| ApiService() { | |||
| _dio = Dio( | |||
| BaseOptions( | |||
| baseUrl: baseUrl, | |||
| connectTimeout: const Duration(seconds: 30), | |||
| receiveTimeout: const Duration(seconds: 30), | |||
| sendTimeout: const Duration(seconds: 30), | |||
| ), | |||
| ); | |||
| // Ajouter un interceptor pour les logs (development) | |||
| _dio.interceptors.add( | |||
| LogInterceptor( | |||
| requestBody: true, | |||
| responseBody: true, | |||
| error: true, | |||
| ), | |||
| ); | |||
| } | |||
| /// Améliorer un média via l'API IA | |||
| Future<EnhancementResult> enhanceMedia( | |||
| File mediaFile, { | |||
| required MediaType mediaType, | |||
| }) async { | |||
| try { | |||
| final fileName = path.basename(mediaFile.path); | |||
| final bytes = await mediaFile.readAsBytes(); | |||
| final formData = FormData.fromMap({ | |||
| 'media': MultipartFile.fromBytes(bytes, filename: fileName), | |||
| 'type': mediaType.name, | |||
| 'versions': '3', | |||
| }); | |||
| final response = await _dio.post<Map<String, dynamic>>( | |||
| aiEnhancementEndpoint, | |||
| data: formData, | |||
| ); | |||
| if (response.statusCode == 200) { | |||
| final data = response.data!; | |||
| final versions = <File>[]; | |||
| // Parser les URLs retournées et télécharger | |||
| if (data['urls'] is List) { | |||
| for (final url in data['urls'] as List) { | |||
| final file = await _downloadFile(url.toString()); | |||
| versions.add(file); | |||
| } | |||
| } | |||
| return ( | |||
| versions: versions, | |||
| processedAt: DateTime.now(), | |||
| error: null, | |||
| ); | |||
| } else { | |||
| throw Exception('API error: ${response.statusCode}'); | |||
| } | |||
| } on DioException catch (e) { | |||
| return ( | |||
| versions: const <File>[], | |||
| processedAt: DateTime.now(), | |||
| error: e.message ?? 'Unknown error', | |||
| ); | |||
| } catch (e) { | |||
| return ( | |||
| versions: const <File>[], | |||
| processedAt: DateTime.now(), | |||
| error: e.toString(), | |||
| ); | |||
| } | |||
| } | |||
| /// Générer du texte basé sur le profil et les préférences | |||
| Future<TextGenerationResult> generateTextProposals({ | |||
| required String profession, | |||
| required String tone, | |||
| required String style, | |||
| required bool includeEmojis, | |||
| required bool includeCommercialInfo, | |||
| String? mediaDescription, | |||
| }) async { | |||
| try { | |||
| final response = await _dio.post<Map<String, dynamic>>( | |||
| textGenerationEndpoint, | |||
| data: { | |||
| 'profession': profession, | |||
| 'tone': tone, | |||
| 'style': style, | |||
| 'include_emojis': includeEmojis, | |||
| 'include_commercial_info': includeCommercialInfo, | |||
| 'media_description': mediaDescription, | |||
| 'num_proposals': 3, | |||
| }, | |||
| ); | |||
| if (response.statusCode == 200) { | |||
| final data = response.data!; | |||
| final proposals = List<String>.from(data['proposals'] as List? ?? []); | |||
| return ( | |||
| proposals: proposals, | |||
| generatedAt: DateTime.now(), | |||
| error: null, | |||
| ); | |||
| } else { | |||
| throw Exception('API error: ${response.statusCode}'); | |||
| } | |||
| } on DioException catch (e) { | |||
| // Retourner des proposals mock pour le développement | |||
| return ( | |||
| proposals: _getMockProposals(includeEmojis), | |||
| generatedAt: DateTime.now(), | |||
| error: e.message, | |||
| ); | |||
| } catch (e) { | |||
| return ( | |||
| proposals: _getMockProposals(includeEmojis), | |||
| generatedAt: DateTime.now(), | |||
| error: e.toString(), | |||
| ); | |||
| } | |||
| } | |||
| /// Télécharger un fichier depuis une URL | |||
| Future<File> _downloadFile(String urlString) async { | |||
| try { | |||
| final response = await _dio.get<Uint8List>( | |||
| urlString, | |||
| options: Options(responseType: ResponseType.bytes), | |||
| ); | |||
| final dir = Directory.systemTemp; | |||
| final file = File('${dir.path}/enhanced_${DateTime.now().millisecondsSinceEpoch}.jpg'); | |||
| await file.writeAsBytes(response.data!); | |||
| return file; | |||
| } catch (e) { | |||
| throw Exception('Failed to download file: $e'); | |||
| } | |||
| } | |||
| /// Propositions mock pour le développement | |||
| List<String> _getMockProposals(bool withEmojis) { | |||
| if (withEmojis) { | |||
| return [ | |||
| '🌟 Découvrez notre nouvelle collection ! ✨ Qualité premium et innovation réunies. 💼 Rejoignez-nous pour transformer vos projets !', | |||
| '💡 Excellence et professionnalisme au rendez-vous ! 🎯 Solutions innovantes pour tous. Faites confiance à notre expertise ! 🚀', | |||
| '🔥 Olé Olé ! 🎉 Créativité et style sans limites. Vos rêves deviennent réalité avec nous ! ✨🌈', | |||
| ]; | |||
| } else { | |||
| return [ | |||
| 'Découvrez notre nouvelle collection. Qualité premium et innovation réunies. Rejoignez-nous pour transformer vos projets.', | |||
| 'Excellence et professionnalisme au rendez-vous. Solutions innovantes pour tous. Faites confiance à notre expertise.', | |||
| 'Créativité et style sans limites. Vos rêves deviennent réalité avec nous.', | |||
| ]; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,73 @@ | |||
| // lib/services/auth_service.dart | |||
| import 'package:flutter_facebook_auth/flutter_facebook_auth.dart'; | |||
| // Un modèle simple pour stocker les informations de l'utilisateur connecté | |||
| class UserProfile { | |||
| final String name; | |||
| final String email; | |||
| final String? pictureUrl; | |||
| UserProfile({required this.name, required this.email, this.pictureUrl}); | |||
| } | |||
| class AuthService { | |||
| // Propriété pour savoir si l'utilisateur est connecté et garder ses infos | |||
| UserProfile? _userProfile; | |||
| UserProfile? get userProfile => _userProfile; | |||
| bool get isLoggedIn => _userProfile != null; | |||
| /// Tente de se connecter avec Facebook | |||
| Future<bool> loginWithFacebook() async { | |||
| try { | |||
| print("[AuthService] Tentative de connexion avec Facebook..."); | |||
| // Lance la popup de connexion Facebook | |||
| final LoginResult result = await FacebookAuth.instance.login( | |||
| permissions: ['public_profile', 'email'], // Demande les permissions | |||
| ); | |||
| // Vérifie si la connexion a réussi | |||
| if (result.status == LoginStatus.success) { | |||
| print("[AuthService] Connexion Facebook réussie."); | |||
| // Récupère les données de l'utilisateur depuis l'API Graph de Facebook | |||
| final userData = await FacebookAuth.instance.getUserData( | |||
| fields: "name,email,picture.width(200)", // Champs demandés | |||
| ); | |||
| // Crée notre objet UserProfile | |||
| _userProfile = UserProfile( | |||
| name: userData['name'], | |||
| email: userData['email'], | |||
| pictureUrl: userData['picture']?['data']?['url'], | |||
| ); | |||
| print("[AuthService] Utilisateur connecté : ${_userProfile!.name}"); | |||
| return true; | |||
| } else { | |||
| print("[AuthService] Échec de la connexion : ${result.status}"); | |||
| print("[AuthService] Message : ${result.message}"); | |||
| return false; | |||
| } | |||
| } catch (e, s) { | |||
| print("[AuthService] ❌ Erreur lors de la connexion Facebook: $e"); | |||
| print(s); | |||
| return false; | |||
| } | |||
| } | |||
| /// Déconnecte l'utilisateur | |||
| Future<void> logout() async { | |||
| try { | |||
| print("[AuthService] Déconnexion..."); | |||
| await FacebookAuth.instance.logOut(); | |||
| _userProfile = null; | |||
| print("[AuthService] Utilisateur déconnecté."); | |||
| } catch (e) { | |||
| print("[AuthService] ❌ Erreur lors de la déconnexion: $e"); | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,95 @@ | |||
| import 'dart:io'; | |||
| import 'package:flutter/services.dart'; | |||
| import 'package:image_picker/image_picker.dart'; | |||
| import 'package:path_provider/path_provider.dart'; | |||
| import 'package:path/path.dart' as path; | |||
| /// Service pour gérer l'accès aux images et vidéos | |||
| class ImageService { | |||
| final ImagePicker _picker = ImagePicker(); | |||
| /// Sélectionner une image depuis la galerie | |||
| Future<File?> pickImageFromGallery() async { | |||
| try { | |||
| final XFile? image = await _picker.pickImage( | |||
| source: ImageSource.gallery, | |||
| maxWidth: 1920, | |||
| maxHeight: 1920, | |||
| imageQuality: 90, | |||
| ); | |||
| return image != null ? File(image.path) : null; | |||
| } on PlatformException catch (e) { | |||
| throw ImagePickerException('Failed to pick image: ${e.message}'); | |||
| } | |||
| } | |||
| /// Capturer une image avec la caméra | |||
| Future<File?> captureImageFromCamera() async { | |||
| try { | |||
| final XFile? photo = await _picker.pickImage( | |||
| source: ImageSource.camera, | |||
| maxWidth: 1920, | |||
| maxHeight: 1920, | |||
| imageQuality: 90, | |||
| ); | |||
| return photo != null ? File(photo.path) : null; | |||
| } on PlatformException catch (e) { | |||
| throw ImagePickerException('Failed to capture image: ${e.message}'); | |||
| } | |||
| } | |||
| /// Sélectionner une vidéo depuis la galerie | |||
| Future<File?> pickVideoFromGallery() async { | |||
| try { | |||
| final XFile? video = await _picker.pickVideo( | |||
| source: ImageSource.gallery, | |||
| maxDuration: const Duration(minutes: 2), | |||
| ); | |||
| return video != null ? File(video.path) : null; | |||
| } on PlatformException catch (e) { | |||
| throw ImagePickerException('Failed to pick video: ${e.message}'); | |||
| } | |||
| } | |||
| /// Capturer une vidéo avec la caméra | |||
| Future<File?> captureVideoFromCamera() async { | |||
| try { | |||
| final XFile? video = await _picker.pickVideo( | |||
| source: ImageSource.camera, | |||
| maxDuration: const Duration(minutes: 2), | |||
| ); | |||
| return video != null ? File(video.path) : null; | |||
| } on PlatformException catch (e) { | |||
| throw ImagePickerException('Failed to capture video: ${e.message}'); | |||
| } | |||
| } | |||
| /// Sauvegarder un fichier dans le dossier documents | |||
| Future<File> saveToAppDirectory(File sourceFile, String fileName) async { | |||
| try { | |||
| final directory = await getApplicationDocumentsDirectory(); | |||
| final filePath = path.join(directory.path, fileName); | |||
| return await sourceFile.copy(filePath); | |||
| } catch (e) { | |||
| throw ImagePickerException('Failed to save file: $e'); | |||
| } | |||
| } | |||
| /// Obtenir la taille d'un fichier en MB | |||
| double getFileSizeInMB(File file) { | |||
| return file.lengthSync() / (1024 * 1024); | |||
| } | |||
| } | |||
| /// Exception personnalisée pour ImageService | |||
| class ImagePickerException implements Exception { | |||
| final String message; | |||
| ImagePickerException(this.message); | |||
| @override | |||
| String toString() => 'ImagePickerException: $message'; | |||
| } | |||
| @@ -0,0 +1,153 @@ | |||
| // lib/main.dart | |||
| import 'dart:io'; | |||
| import 'package:flutter/material.dart'; | |||
| // --- IMPORTS MANQUANTS AJOUTÉS --- | |||
| import 'package:social_content_creator/repositories/ai_repository.dart'; | |||
| // Les autres imports d'écrans sont corrects | |||
| import 'package:social_content_creator/presentation/screens/ai_enhancement/ai_enhancement_screen.dart'; | |||
| import 'package:social_content_creator/presentation/screens/export/export_screen.dart'; | |||
| import 'package:social_content_creator/presentation/screens/home/login_screen.dart'; | |||
| import 'package:social_content_creator/presentation/screens/home/home_screen.dart'; | |||
| import 'package:social_content_creator/presentation/screens/image_preview/image_preview_screen.dart'; | |||
| import 'package:social_content_creator/presentation/screens/media_picker/media_picker_screen.dart'; | |||
| import 'package:social_content_creator/presentation/screens/post_preview/post_preview_screen.dart'; | |||
| import 'package:social_content_creator/presentation/screens/post_refinement/post_refinement_screen.dart'; | |||
| import 'package:social_content_creator/presentation/screens/profile_setup/profile_setup_screen.dart'; | |||
| import 'package:social_content_creator/presentation/screens/text_generation/text_generation_screen.dart'; | |||
| import 'package:social_content_creator/routes/app_routes.dart'; | |||
| void main() { | |||
| runApp(const MyApp()); | |||
| } | |||
| class MyApp extends StatelessWidget { | |||
| const MyApp({super.key}); | |||
| static const bool _devSkipLogin = true; | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| final Color seedColor = Colors.blue; | |||
| return MaterialApp( | |||
| title: 'Social Content Creator', | |||
| theme: ThemeData( | |||
| colorScheme: ColorScheme.fromSeed( | |||
| seedColor: seedColor, | |||
| brightness: Brightness.light, | |||
| ), | |||
| useMaterial3: true, | |||
| ), | |||
| darkTheme: ThemeData( | |||
| colorScheme: ColorScheme.fromSeed( | |||
| seedColor: seedColor, | |||
| brightness: Brightness.dark, | |||
| ), | |||
| useMaterial3: true, | |||
| ), | |||
| themeMode: ThemeMode.system, | |||
| debugShowCheckedModeBanner: false, | |||
| initialRoute: _devSkipLogin ? AppRoutes.home : AppRoutes.login, | |||
| // --- GESTIONNAIRE DE ROUTES ENTIÈREMENT CORRIGÉ ET COMPLÉTÉ --- | |||
| onGenerateRoute: (settings) { | |||
| switch (settings.name) { | |||
| // --- Routes simples sans arguments --- | |||
| case '/': | |||
| case AppRoutes.login: | |||
| return MaterialPageRoute(builder: (_) => const LoginScreen()); | |||
| case AppRoutes.home: | |||
| return MaterialPageRoute(builder: (_) => const HomeScreen()); | |||
| case AppRoutes.profileSetup: | |||
| return MaterialPageRoute(builder: (_) => const ProfileSetupScreen()); | |||
| case AppRoutes.mediaPicker: | |||
| return MaterialPageRoute(builder: (_) => const MediaPickerScreen()); | |||
| case AppRoutes.export: | |||
| return MaterialPageRoute(builder: (_) => const ExportScreen()); | |||
| // --- Routes avec arguments --- | |||
| case AppRoutes.aiEnhancement: | |||
| if (settings.arguments is Map<String, dynamic>) { | |||
| final args = settings.arguments as Map<String, dynamic>; | |||
| if (args.containsKey('image') && | |||
| args['image'] is File && | |||
| args.containsKey('prompt') && | |||
| args['prompt'] is String) { | |||
| return MaterialPageRoute( | |||
| builder: (_) => AiEnhancementScreen( | |||
| image: args['image']!, | |||
| prompt: args['prompt']!, | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| return _errorRoute("Arguments invalides pour AiEnhancementScreen."); | |||
| case AppRoutes.imagePreview: | |||
| if (settings.arguments is String) { | |||
| final imageBase64 = settings.arguments as String; | |||
| return MaterialPageRoute( | |||
| builder: (_) => ImagePreviewScreen(imageBase64: imageBase64), | |||
| ); | |||
| } | |||
| return _errorRoute("Argument (String) invalide pour ImagePreviewScreen"); | |||
| case AppRoutes.textGeneration: | |||
| final args = settings.arguments; | |||
| if (args is Map<String, dynamic>) { | |||
| final imageBase64 = args['imageBase64'] as String?; | |||
| final aiRepository = args['aiRepository'] as AiRepository?; | |||
| if (imageBase64 != null && aiRepository != null) { | |||
| return MaterialPageRoute( | |||
| builder: (_) => TextGenerationScreen( | |||
| arguments: TextGenerationScreenArguments( | |||
| imageBase64: imageBase64, | |||
| aiRepository: aiRepository, | |||
| ), | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| // L'erreur était ici : il manquait le cas de fallback | |||
| return _errorRoute("Arguments invalides pour TextGenerationScreen."); | |||
| // --- ROUTE MANQUANTE AJOUTÉE --- | |||
| case AppRoutes.postRefinement: | |||
| if (settings.arguments is PostRefinementScreenArguments) { | |||
| final args = settings.arguments as PostRefinementScreenArguments; | |||
| return MaterialPageRoute( | |||
| builder: (_) => PostRefinementScreen(arguments: args), | |||
| ); | |||
| } | |||
| return _errorRoute("Arguments (PostRefinementScreenArguments) invalides pour PostRefinementScreen."); | |||
| // --- ROUTE MANQUANTE AJOUTÉE --- | |||
| case AppRoutes.postPreview: | |||
| if (settings.arguments is PostPreviewArguments) { | |||
| final args = settings.arguments as PostPreviewArguments; | |||
| return MaterialPageRoute( | |||
| builder: (_) => PostPreviewScreen(arguments: args), | |||
| ); | |||
| } | |||
| return _errorRoute("Arguments (PostPreviewArguments) invalides pour PostPreviewScreen."); | |||
| default: | |||
| return _errorRoute("Route non trouvée: ${settings.name}"); | |||
| } | |||
| }, | |||
| ); | |||
| } | |||
| // Méthode helper pour afficher une page d'erreur propre | |||
| MaterialPageRoute _errorRoute(String message) { | |||
| return MaterialPageRoute( | |||
| builder: (_) => Scaffold( | |||
| appBar: AppBar(title: const Text('Erreur de Navigation')), | |||
| body: Center( | |||
| child: Padding( | |||
| padding: const EdgeInsets.all(16.0), | |||
| child: Text(message, textAlign: TextAlign.center), | |||
| )), | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,270 @@ | |||
| import 'dart:async'; | |||
| import 'dart:convert'; | |||
| import 'dart:io'; | |||
| import 'dart:typed_data'; | |||
| import 'package:flutter/material.dart';import 'package:social_content_creator/routes/app_routes.dart'; | |||
| import 'package:social_content_creator/services/image_editing_service.dart'; // Contrat | |||
| import 'package:social_content_creator/services/stable_diffusion_service.dart'; // Moteur 1 | |||
| import 'package:social_content_creator/services/gemini_service.dart'; // Moteur 2 | |||
| import 'package:image/image.dart' as img; | |||
| // Énumération pour le choix du moteur d'IA | |||
| enum ImageEngine { stableDiffusion, gemini } | |||
| class AiEnhancementScreen extends StatefulWidget { | |||
| final File image; | |||
| final String prompt; | |||
| const AiEnhancementScreen({ | |||
| super.key, | |||
| required this.image, | |||
| required this.prompt, | |||
| }); | |||
| @override | |||
| State<AiEnhancementScreen> createState() => _AiEnhancementScreenState(); | |||
| } | |||
| enum GenerationState { idle, generating, done, error } | |||
| class _AiEnhancementScreenState extends State<AiEnhancementScreen> { | |||
| GenerationState _generationState = GenerationState.idle; | |||
| final List<Uint8List> _generatedImagesData = []; | |||
| StreamSubscription? _imageStreamSubscription; | |||
| // --- NOUVEAUX ÉLÉMENTS --- | |||
| // 1. Instancier les deux services dans une map | |||
| final Map<ImageEngine, ImageEditingService> _services = { | |||
| ImageEngine.stableDiffusion: StableDiffusionService(), | |||
| ImageEngine.gemini: GeminiService(), | |||
| }; | |||
| // 2. Garder en mémoire le moteur sélectionné (Stable Diffusion par défaut) | |||
| ImageEngine _selectedEngine = ImageEngine.stableDiffusion; | |||
| // --- FIN DES NOUVEAUX ÉLÉMENTS --- | |||
| Future<void> _generateImageVariations() async { | |||
| if (_generationState == GenerationState.generating) return; | |||
| setState(() { | |||
| _generatedImagesData.clear(); | |||
| _generationState = GenerationState.generating; | |||
| }); | |||
| try { | |||
| // Préparation de l'image (logique inchangée) | |||
| final imageBytes = await widget.image.readAsBytes(); | |||
| final originalImage = img.decodeImage(imageBytes); | |||
| if (originalImage == null) throw Exception("Impossible de décoder l'image."); | |||
| final resizedImage = img.copyResize(originalImage, width: 1024); | |||
| final resizedImageBytes = img.encodeJpg(resizedImage, quality: 90); | |||
| final imageBase64 = base64Encode(resizedImageBytes); | |||
| await _imageStreamSubscription?.cancel(); | |||
| // --- UTILISATION DU SERVICE SÉLECTIONNÉ --- | |||
| // On récupère le bon service (Stable Diffusion ou Gemini) depuis la map | |||
| final activeService = _services[_selectedEngine]!; | |||
| _imageStreamSubscription = activeService.editImage( | |||
| imageBase64, | |||
| widget.prompt, | |||
| resizedImage.width, | |||
| resizedImage.height, | |||
| // On demande 3 images à Stable Diffusion, Gemini gèrera ce paramètre | |||
| numberOfImages: _selectedEngine == ImageEngine.stableDiffusion ? 3 : 1, | |||
| ).listen( | |||
| (receivedBase64Image) { | |||
| setState(() => _generatedImagesData.add(base64Decode(receivedBase64Image))); | |||
| }, | |||
| onError: (error) { | |||
| if (!mounted) return; | |||
| setState(() => _generationState = GenerationState.error); | |||
| ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Erreur de génération : $error"))); | |||
| }, | |||
| onDone: () { | |||
| if (!mounted) return; | |||
| setState(() => _generationState = GenerationState.done); | |||
| }, | |||
| ); | |||
| } catch (e) { | |||
| if (!mounted) return; | |||
| setState(() => _generationState = GenerationState.error); | |||
| ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Erreur : ${e.toString()}"))); | |||
| } | |||
| } | |||
| void _navigateToPreview(int index) { | |||
| if (index >= _generatedImagesData.length) return; | |||
| final selectedImageData = _generatedImagesData[index]; | |||
| final imageBase64 = base64Encode(selectedImageData); | |||
| Navigator.pushNamed(context, AppRoutes.imagePreview, arguments: imageBase64); | |||
| } | |||
| @override | |||
| void dispose() { | |||
| _imageStreamSubscription?.cancel(); | |||
| super.dispose(); | |||
| } | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| const double bottomButtonHeight = 90.0; | |||
| return Scaffold( | |||
| appBar: AppBar(title: const Text("3. Choisir une variation")), | |||
| body: Stack( | |||
| children: [ | |||
| SingleChildScrollView( | |||
| padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, bottomButtonHeight), | |||
| child: Column( | |||
| crossAxisAlignment: CrossAxisAlignment.start, | |||
| children: [ | |||
| Text("Image Originale", style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), | |||
| const SizedBox(height: 8), | |||
| SizedBox( | |||
| height: 300, | |||
| width: double.infinity, | |||
| child: ClipRRect( | |||
| borderRadius: BorderRadius.circular(12.0), | |||
| child: Image.file(widget.image, fit: BoxFit.cover), | |||
| ), | |||
| ), | |||
| const SizedBox(height: 16), | |||
| // --- AJOUT DU SÉLECTEUR DE MOTEUR D'IA --- | |||
| Container( | |||
| padding: const EdgeInsets.all(12), | |||
| decoration: BoxDecoration( | |||
| color: Theme.of(context).colorScheme.primary.withOpacity(0.05), | |||
| borderRadius: BorderRadius.circular(12), | |||
| border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)) | |||
| ), | |||
| child: Column( | |||
| crossAxisAlignment: CrossAxisAlignment.stretch, | |||
| children: [ | |||
| Text( | |||
| "Moteur d'IA pour la variation", | |||
| style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), | |||
| textAlign: TextAlign.center, | |||
| ), | |||
| const SizedBox(height: 12), | |||
| SegmentedButton<ImageEngine>( | |||
| showSelectedIcon: false, | |||
| segments: const <ButtonSegment<ImageEngine>>[ | |||
| ButtonSegment<ImageEngine>( | |||
| value: ImageEngine.stableDiffusion, | |||
| label: Text('Stable Diffusion'), | |||
| icon: Icon(Icons.auto_awesome_outlined), | |||
| ), | |||
| ButtonSegment<ImageEngine>( | |||
| value: ImageEngine.gemini, | |||
| label: Text('Gemini'), | |||
| icon: Icon(Icons.bubble_chart_outlined), | |||
| ), | |||
| ], | |||
| selected: {_selectedEngine}, | |||
| onSelectionChanged: (Set<ImageEngine> newSelection) { | |||
| // On ne change de moteur que si la génération n'est pas en cours | |||
| if (_generationState != GenerationState.generating) { | |||
| setState(() { | |||
| _selectedEngine = newSelection.first; | |||
| }); | |||
| } | |||
| }, | |||
| ), | |||
| const SizedBox(height: 16), | |||
| // On garde le prompt de guidage dans le même encart | |||
| ExpansionTile( | |||
| title: const Text("Voir le prompt de guidage"), | |||
| initiallyExpanded: false, | |||
| tilePadding: EdgeInsets.zero, | |||
| childrenPadding: const EdgeInsets.only(top: 8), | |||
| children: [ | |||
| Text(widget.prompt, style: const TextStyle(fontStyle: FontStyle.italic)), | |||
| ], | |||
| ), | |||
| ], | |||
| ), | |||
| ), | |||
| // --- FIN DE L'AJOUT --- | |||
| const SizedBox(height: 24), | |||
| Text("Variations (cliquez pour choisir)", style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), | |||
| const SizedBox(height: 16), | |||
| _buildGeneratedVariationsGrid(), | |||
| ], | |||
| ), | |||
| ), | |||
| Positioned( | |||
| bottom: 0, | |||
| left: 0, | |||
| right: 0, | |||
| child: Container( | |||
| color: Theme.of(context).scaffoldBackgroundColor, | |||
| padding: const EdgeInsets.all(16.0), | |||
| child: FilledButton.icon( | |||
| icon: _generationState == GenerationState.generating ? const SizedBox.shrink() : const Icon(Icons.auto_awesome), | |||
| label: Text(_generationState == GenerationState.generating ? 'Génération en cours...' : 'Générer à nouveau'), | |||
| onPressed: _generationState == GenerationState.generating ? null : _generateImageVariations, | |||
| style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), | |||
| ), | |||
| ), | |||
| ), | |||
| ], | |||
| ), | |||
| ); | |||
| } | |||
| // Le reste du fichier est inchangé (_buildGeneratedVariationsGrid) | |||
| Widget _buildGeneratedVariationsGrid() { | |||
| // Si la génération est terminée et qu'il n'y a aucune image (cas d'erreur silencieuse) | |||
| if (_generationState == GenerationState.done && _generatedImagesData.isEmpty) { | |||
| return Container( | |||
| height: 100, | |||
| decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.red.shade200)), | |||
| child: const Center(child: Text("La génération n'a produit aucune image.", textAlign: TextAlign.center)), | |||
| ); | |||
| } | |||
| if (_generationState == GenerationState.idle) { | |||
| return Container( | |||
| height: 100, | |||
| decoration: BoxDecoration(color: Colors.grey.shade200, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey.shade400, style: BorderStyle.solid)), | |||
| child: const Center(child: Text("Cliquez sur 'Générer' pour commencer.")), | |||
| ); | |||
| } | |||
| // On affiche 3 slots, même si Gemini n'en remplit qu'un | |||
| int displayCount = 3; | |||
| if (_selectedEngine == ImageEngine.gemini && _generationState != GenerationState.generating) { | |||
| displayCount = _generatedImagesData.length > 0 ? _generatedImagesData.length : 1; | |||
| } | |||
| return Row( | |||
| mainAxisAlignment: MainAxisAlignment.start, | |||
| children: List.generate(displayCount, (index) { | |||
| bool hasImage = index < _generatedImagesData.length; | |||
| return Expanded( | |||
| child: Padding( | |||
| padding: const EdgeInsets.symmetric(horizontal: 4.0), | |||
| child: AspectRatio( | |||
| aspectRatio: 1.0, | |||
| child: GestureDetector( | |||
| onTap: hasImage ? () => _navigateToPreview(index) : null, | |||
| child: hasImage | |||
| ? ClipRRect(borderRadius: BorderRadius.circular(8), child: Image.memory(_generatedImagesData[index], fit: BoxFit.cover)) | |||
| : Container( | |||
| decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(8)), | |||
| child: _generationState == GenerationState.generating ? const Center(child: CircularProgressIndicator()) : const SizedBox(), | |||
| ), | |||
| ), | |||
| ), | |||
| ), | |||
| ); | |||
| }), | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,29 @@ | |||
| import 'package:flutter/material.dart'; | |||
| final class ExportScreen extends StatelessWidget { | |||
| const ExportScreen({super.key}); | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| return Scaffold( | |||
| appBar: AppBar(title: const Text('Partager')), | |||
| body: Center( | |||
| child: Column( | |||
| mainAxisAlignment: MainAxisAlignment.center, | |||
| children: [ | |||
| const Icon(Icons.share, size: 64), | |||
| const SizedBox(height: 24), | |||
| const Text('Sélectionnez les réseaux'), | |||
| const SizedBox(height: 32), | |||
| FilledButton( | |||
| onPressed: () => ScaffoldMessenger.of(context).showSnackBar( | |||
| const SnackBar(content: Text('Partage réussi!')), | |||
| ), | |||
| child: const Text('Partager maintenant'), | |||
| ), | |||
| ], | |||
| ), | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,54 @@ | |||
| import 'package:flutter/material.dart'; | |||
| import 'package:social_content_creator/routes/app_routes.dart'; | |||
| class HomeScreen extends StatelessWidget { | |||
| const HomeScreen({super.key}); | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| return Scaffold( | |||
| appBar: AppBar( | |||
| automaticallyImplyLeading: false, | |||
| title: const Text("Accueil"), | |||
| // Optionnel : Ajoutez un bouton de déconnexion | |||
| actions: [ | |||
| IconButton( | |||
| icon: const Icon(Icons.logout), | |||
| tooltip: "Déconnexion", | |||
| onPressed: () { | |||
| // Navigue vers l'écran de login et supprime toutes les routes précédentes | |||
| Navigator.of(context).pushNamedAndRemoveUntil( | |||
| AppRoutes.login, | |||
| (Route<dynamic> route) => false | |||
| ); | |||
| }, | |||
| ) | |||
| ], | |||
| ), | |||
| body: Center( | |||
| child: Column( | |||
| mainAxisAlignment: MainAxisAlignment.center, | |||
| children: [ | |||
| const Text( | |||
| "Vous êtes connecté !", | |||
| style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), | |||
| ), | |||
| const SizedBox(height: 40), | |||
| ElevatedButton.icon( | |||
| icon: const Icon(Icons.add_photo_alternate_outlined), | |||
| label: const Text("Commencer une nouvelle publication"), | |||
| onPressed: () { | |||
| // Lance le parcours de création de post existant | |||
| Navigator.of(context).pushNamed(AppRoutes.mediaPicker); | |||
| }, | |||
| style: ElevatedButton.styleFrom( | |||
| padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), | |||
| ), | |||
| ) | |||
| ], | |||
| ), | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,58 @@ | |||
| import 'package:flutter/material.dart'; | |||
| import '../../../data/services/auth_service.dart'; // Assurez-vous que le chemin est correct | |||
| class LoginScreen extends StatefulWidget { | |||
| const LoginScreen({super.key}); | |||
| @override | |||
| State<LoginScreen> createState() => _LoginScreenState(); | |||
| } | |||
| class _LoginScreenState extends State<LoginScreen> { | |||
| final AuthService _authService = AuthService(); | |||
| bool _isLoading = false; | |||
| void _handleFacebookLogin() async { | |||
| setState(() { | |||
| _isLoading = true; | |||
| }); | |||
| final bool success = await _authService.loginWithFacebook(); | |||
| setState(() { | |||
| _isLoading = false; | |||
| }); | |||
| if (success && mounted) { | |||
| // Naviguer vers l'écran principal de l'application | |||
| // Par exemple : | |||
| Navigator.of(context).pushReplacementNamed('/home'); | |||
| } else if (mounted) { | |||
| // Afficher un message d'erreur | |||
| ScaffoldMessenger.of(context).showSnackBar( | |||
| const SnackBar(content: Text("La connexion a échoué. Veuillez réessayer.")), | |||
| ); | |||
| } | |||
| } | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| return Scaffold( | |||
| appBar: AppBar(title: const Text("Connexion")), | |||
| body: Center( | |||
| child: _isLoading | |||
| ? const CircularProgressIndicator() | |||
| : ElevatedButton.icon( | |||
| icon: const Icon(Icons.facebook), | |||
| label: const Text("Se connecter avec Facebook"), | |||
| onPressed: _handleFacebookLogin, | |||
| style: ElevatedButton.styleFrom( | |||
| backgroundColor: const Color(0xFF1877F2), // Couleur de Facebook | |||
| foregroundColor: Colors.white, | |||
| padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), | |||
| ), | |||
| ), | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,79 @@ | |||
| // lib/presentation/screens/image_preview/image_preview_screen.dart | |||
| import 'dart:convert'; | |||
| import 'package:flutter/material.dart'; | |||
| // --- CORRECTION 1 : IMPORTER SEULEMENT CE QUI EST NÉCESSAIRE --- | |||
| import '../../../repositories/ai_repository.dart'; | |||
| import '../../../routes/app_routes.dart'; | |||
| // L'import de 'ollama_service.dart' est supprimé. | |||
| // --- CORRECTION 2 : SIMPLIFIER LE CONSTRUCTEUR --- | |||
| // Cet écran n'a plus besoin de recevoir un service, car l'écran suivant | |||
| // instanciera lui-même le AiRepository dont il a besoin. | |||
| final class ImagePreviewScreen extends StatelessWidget { | |||
| final String imageBase64; | |||
| const ImagePreviewScreen({super.key, required this.imageBase64}); | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| // Le AiRepository est instancié ici, uniquement si on en a besoin pour la navigation. | |||
| // Dans ce cas, on le passe à l'écran suivant. | |||
| final AiRepository aiRepository = AiRepository(); | |||
| try { | |||
| final imageBytes = base64Decode(imageBase64); | |||
| return Scaffold( | |||
| appBar: AppBar( | |||
| title: const Text("Aperçu de l'image"), | |||
| backgroundColor: Colors.black, | |||
| elevation: 0, | |||
| ), | |||
| backgroundColor: Colors.black, | |||
| body: SafeArea( | |||
| child: Center( | |||
| child: InteractiveViewer( | |||
| panEnabled: true, | |||
| minScale: 0.5, | |||
| maxScale: 4.0, | |||
| child: Image.memory( | |||
| imageBytes, | |||
| fit: BoxFit.contain, | |||
| ), | |||
| ), | |||
| ), | |||
| ), | |||
| floatingActionButton: FloatingActionButton.extended( | |||
| // --- CORRECTION 3 : SIMPLIFIER LA LOGIQUE DE NAVIGATION --- | |||
| onPressed: () { | |||
| // On navigue vers l'écran suivant en lui passant les arguments nécessaires. | |||
| // L'écran 'TextGeneration' aura besoin de l'image et du repository pour travailler. | |||
| Navigator.pushNamed( | |||
| context, | |||
| AppRoutes.textGeneration, // La route vers l'écran de génération de texte | |||
| arguments: { | |||
| 'imageBase64': imageBase64, | |||
| 'aiRepository': aiRepository, | |||
| }, | |||
| ); | |||
| }, | |||
| label: const Text('Utiliser cette image'), | |||
| icon: const Icon(Icons.check), | |||
| ), | |||
| ); | |||
| } catch (e) { | |||
| // Le bloc catch reste inchangé, il est correct. | |||
| return Scaffold( | |||
| appBar: AppBar(title: const Text('Erreur')), | |||
| body: const Center( | |||
| child: Text( | |||
| 'Erreur : aucune image à afficher. Les données sont peut-être corrompues.', | |||
| textAlign: TextAlign.center, | |||
| ), | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,147 @@ | |||
| // lib/presentation/screens/media_picker/media_picker_screen.dart | |||
| import 'dart:convert'; // <<< NÉCESSAIRE POUR BASE64 | |||
| import 'dart:io'; | |||
| import 'package:flutter/material.dart'; | |||
| import 'package:image_picker/image_picker.dart'; | |||
| import 'package:social_content_creator/routes/app_routes.dart'; | |||
| import 'package:social_content_creator/services/image_analysis_service.dart'; // <<< IMPORT CORRECT | |||
| class MediaPickerScreen extends StatefulWidget { | |||
| const MediaPickerScreen({super.key}); | |||
| @override | |||
| State<MediaPickerScreen> createState() => _MediaPickerScreenState(); | |||
| } | |||
| class _MediaPickerScreenState extends State<MediaPickerScreen> { | |||
| final _picker = ImagePicker(); | |||
| XFile? _selectedMedia; | |||
| // On instancie la classe CONCRÈTE | |||
| final ImageAnalysisService _analysisService = OllamaImageAnalysisService(); | |||
| bool _isAnalyzing = false; | |||
| Future<void> _pickImage(ImageSource source) async { | |||
| try { | |||
| final file = await _picker.pickImage(source: source, imageQuality: 85, maxWidth: 1280); | |||
| if (file != null) { | |||
| setState(() => _selectedMedia = file); | |||
| _analyzeAndNavigate(); | |||
| } | |||
| } catch (e) { | |||
| if (!mounted) return; | |||
| ScaffoldMessenger.of(context).showSnackBar( | |||
| SnackBar(content: Text("Erreur lors de la sélection de l'image: $e")), | |||
| ); | |||
| } | |||
| } | |||
| /// Fonction qui analyse l'image et navigue vers l'écran suivant. | |||
| Future<void> _analyzeAndNavigate() async { | |||
| if (_selectedMedia == null) return; | |||
| setState(() => _isAnalyzing = true); | |||
| try { | |||
| final imageFile = File(_selectedMedia!.path); | |||
| // --- CORRECTION APPLIQUÉE ICI --- | |||
| // 1. Convertir l'image en base64, comme attendu par le service. | |||
| final imageBytes = await imageFile.readAsBytes(); | |||
| final String imageBase64 = base64Encode(imageBytes); | |||
| // 2. Appeler la bonne méthode (`analyzeImage`) avec le bon argument (`base64Image`). | |||
| final String imagePrompt = await _analysisService.analyzeImage(imageBase64); | |||
| // --- FIN DE LA CORRECTION --- | |||
| if (!mounted) return; | |||
| // 3. Navigation avec l'image et le prompt | |||
| Navigator.pushNamed( | |||
| context, | |||
| AppRoutes.aiEnhancement, | |||
| arguments: <String, dynamic>{ | |||
| 'image': imageFile, | |||
| 'prompt': imagePrompt, | |||
| }, | |||
| ); | |||
| } catch (e) { | |||
| if (!mounted) return; | |||
| ScaffoldMessenger.of(context).showSnackBar( | |||
| SnackBar(content: Text("Erreur lors de l'analyse de l'image : $e")), | |||
| ); | |||
| } finally { | |||
| if (mounted) { | |||
| setState(() => _isAnalyzing = false); | |||
| } | |||
| } | |||
| } | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| return Scaffold( | |||
| appBar: AppBar(title: const Text('1. Choisir une Image')), | |||
| body: Stack( | |||
| children: [ | |||
| SafeArea( | |||
| child: Column( | |||
| crossAxisAlignment: CrossAxisAlignment.stretch, | |||
| children: [ | |||
| Expanded( | |||
| child: Center( | |||
| child: Padding( | |||
| padding: const EdgeInsets.all(24.0), | |||
| child: Column( | |||
| mainAxisSize: MainAxisSize.min, | |||
| crossAxisAlignment: CrossAxisAlignment.stretch, | |||
| children: [ | |||
| const Icon(Icons.camera_enhance, size: 80, color: Colors.grey), | |||
| const SizedBox(height: 24), | |||
| Text( | |||
| "Choisissez une image pour commencer", | |||
| style: Theme.of(context).textTheme.headlineSmall, | |||
| textAlign: TextAlign.center, | |||
| ), | |||
| const SizedBox(height: 48), | |||
| FilledButton.icon( | |||
| onPressed: _isAnalyzing ? null : () => _pickImage(ImageSource.gallery), | |||
| icon: const Icon(Icons.photo_library_outlined), | |||
| label: const Text('Importer depuis la galerie'), | |||
| style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)), | |||
| ), | |||
| const SizedBox(height: 12), | |||
| OutlinedButton.icon( | |||
| onPressed: _isAnalyzing ? null : () => _pickImage(ImageSource.camera), | |||
| icon: const Icon(Icons.camera_alt_outlined), | |||
| label: const Text('Prendre une photo'), | |||
| style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)), | |||
| ), | |||
| ], | |||
| ), | |||
| ), | |||
| ), | |||
| ), | |||
| ], | |||
| ), | |||
| ), | |||
| if (_isAnalyzing) | |||
| Container( | |||
| color: Colors.black.withOpacity(0.5), | |||
| child: const Center( | |||
| child: Column( | |||
| mainAxisSize: MainAxisSize.min, | |||
| children: [ | |||
| CircularProgressIndicator(), | |||
| SizedBox(height: 16), | |||
| Text("Analyse de l'image...", style: TextStyle(color: Colors.white, fontSize: 16)), | |||
| ], | |||
| ), | |||
| ), | |||
| ), | |||
| ], | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,131 @@ | |||
| // lib/presentation/screens/post_preview/post_preview_screen.dart | |||
| import 'dart:convert'; | |||
| import 'package:flutter/material.dart'; | |||
| import '../../../repositories/ai_repository.dart'; | |||
| import '../../widgets/creation_flow_layout.dart'; | |||
| // --- ACTION 1 : CRÉER LA CLASSE D'ARGUMENTS MANQUANTE --- | |||
| // Cette classe encapsule toutes les données nécessaires pour cet écran. | |||
| class PostPreviewArguments { | |||
| final String imageBase64; | |||
| final String text; | |||
| final AiRepository aiRepository; | |||
| PostPreviewArguments({ | |||
| required this.imageBase64, | |||
| required this.text, | |||
| required this.aiRepository, | |||
| }); | |||
| } | |||
| // --- ACTION 2 : ADAPTER L'ÉCRAN POUR UTILISER LA CLASSE D'ARGUMENTS --- | |||
| final class PostPreviewScreen extends StatelessWidget { | |||
| // L'écran attend maintenant un seul objet 'arguments' | |||
| final PostPreviewArguments arguments; | |||
| const PostPreviewScreen({ | |||
| super.key, | |||
| required this.arguments, // Le constructeur est simplifié | |||
| }); | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| return CreationFlowLayout( | |||
| // Adaptez ce chiffre au nombre total d'étapes de votre flux. | |||
| currentStep: 6, | |||
| title: "Aperçu & Publication", | |||
| child: SingleChildScrollView( | |||
| padding: const EdgeInsets.all(16.0), | |||
| child: Column( | |||
| children: [ | |||
| // Widget de carte simulant un post de réseau social | |||
| Card( | |||
| clipBehavior: Clip.antiAlias, | |||
| elevation: 4, | |||
| shape: RoundedRectangleBorder( | |||
| borderRadius: BorderRadius.circular(12), | |||
| ), | |||
| child: Column( | |||
| crossAxisAlignment: CrossAxisAlignment.start, | |||
| children: [ | |||
| // L'image du post | |||
| Image.memory( | |||
| // On accède aux données via l'objet 'arguments' | |||
| base64Decode(arguments.imageBase64), | |||
| width: double.infinity, | |||
| height: 350, | |||
| fit: BoxFit.cover, | |||
| ), | |||
| // Le texte final du post | |||
| Padding( | |||
| padding: const EdgeInsets.all(16.0), | |||
| child: Text( | |||
| // On accède aux données via l'objet 'arguments' | |||
| arguments.text, | |||
| style: const TextStyle(fontSize: 16), | |||
| ), | |||
| ), | |||
| // Barre d'actions (inchangée) | |||
| Padding( | |||
| padding: const EdgeInsets.symmetric( | |||
| horizontal: 16.0, vertical: 8.0), | |||
| child: Row( | |||
| mainAxisAlignment: MainAxisAlignment.spaceAround, | |||
| children: [ | |||
| Icon(Icons.favorite_border, | |||
| color: Theme.of(context) | |||
| .colorScheme | |||
| .onSurface | |||
| .withOpacity(0.6)), | |||
| Icon(Icons.mode_comment_outlined, | |||
| color: Theme.of(context) | |||
| .colorScheme | |||
| .onSurface | |||
| .withOpacity(0.6)), | |||
| Icon(Icons.send_outlined, | |||
| color: Theme.of(context) | |||
| .colorScheme | |||
| .onSurface | |||
| .withOpacity(0.6)), | |||
| ], | |||
| ), | |||
| ), | |||
| ], | |||
| ), | |||
| ), | |||
| const SizedBox(height: 24), | |||
| // Bouton final de publication | |||
| SizedBox( | |||
| width: double.infinity, | |||
| child: FilledButton.icon( | |||
| onPressed: () { | |||
| // La logique de publication utilise maintenant les arguments | |||
| // arguments.aiRepository.publishPost( | |||
| // image: arguments.imageBase64, | |||
| // text: arguments.text, | |||
| // ); | |||
| ScaffoldMessenger.of(context).showSnackBar( | |||
| const SnackBar( | |||
| content: Text('Publication simulée avec succès !'), | |||
| backgroundColor: Colors.green, | |||
| ), | |||
| ); | |||
| // Potentiellement, naviguer vers l'accueil après publication | |||
| // Navigator.of(context).popUntil((route) => route.isFirst); | |||
| }, | |||
| icon: const Icon(Icons.check_circle_outline), | |||
| label: const Text('Publier maintenant'), | |||
| style: FilledButton.styleFrom( | |||
| padding: const EdgeInsets.symmetric(vertical: 16), | |||
| ), | |||
| ), | |||
| ), | |||
| ], | |||
| ), | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,175 @@ | |||
| // lib/presentation/screens/post_refinement/post_refinement_screen.dart | |||
| import 'package:flutter/material.dart'; | |||
| // --- CORRECTION 1 : IMPORTER LE REPOSITORY --- | |||
| import '../../../repositories/ai_repository.dart'; | |||
| import 'package:social_content_creator/routes/app_routes.dart'; | |||
| import '../../widgets/creation_flow_layout.dart'; | |||
| import '../post_preview/post_preview_screen.dart'; | |||
| // L'import de 'ollama_service.dart' est supprimé. | |||
| // --- CORRECTION 2 : DÉFINIR UNE CLASSE D'ARGUMENTS PROPRE --- | |||
| class PostRefinementScreenArguments { | |||
| final String initialText; | |||
| final String imageBase64; | |||
| final AiRepository aiRepository; | |||
| PostRefinementScreenArguments({ | |||
| required this.initialText, | |||
| required this.imageBase64, | |||
| required this.aiRepository, | |||
| }); | |||
| } | |||
| class PostRefinementScreen extends StatefulWidget { | |||
| // Le constructeur attend maintenant la classe d'arguments. | |||
| final PostRefinementScreenArguments arguments; | |||
| const PostRefinementScreen({ | |||
| super.key, | |||
| required this.arguments, | |||
| }); | |||
| @override | |||
| State<PostRefinementScreen> createState() => _PostRefinementScreenState(); | |||
| } | |||
| class _PostRefinementScreenState extends State<PostRefinementScreen> { | |||
| late final TextEditingController _postTextController; | |||
| final TextEditingController _promptController = TextEditingController(); | |||
| bool _isImproving = false; | |||
| @override | |||
| void initState() { | |||
| super.initState(); | |||
| // On initialise le texte depuis les arguments reçus. | |||
| _postTextController = TextEditingController(text: widget.arguments.initialText); | |||
| } | |||
| @override | |||
| void dispose() { | |||
| _postTextController.dispose(); | |||
| _promptController.dispose(); | |||
| super.dispose(); | |||
| } | |||
| // --- CORRECTION 3 : UTILISER LE REPOSITORY POUR L'AMÉLIORATION --- | |||
| Future<void> _handleImproveWithAI() async { | |||
| final instruction = _promptController.text; | |||
| if (instruction.isEmpty || _isImproving) return; | |||
| if (!mounted) return; | |||
| setState(() => _isImproving = true); | |||
| try { | |||
| // On appelle la méthode du Repository, qui se chargera de déléguer au bon service. | |||
| final improvedText = await widget.arguments.aiRepository.improvePostText( | |||
| originalText: _postTextController.text, | |||
| userInstruction: instruction, | |||
| ); | |||
| if (mounted) { | |||
| setState(() { | |||
| _postTextController.text = improvedText; | |||
| _promptController.clear(); | |||
| }); | |||
| } | |||
| } catch (e) { | |||
| if (mounted) { | |||
| ScaffoldMessenger.of(context).showSnackBar(SnackBar( | |||
| content: Text("Erreur d'amélioration : ${e.toString()}"), | |||
| backgroundColor: Colors.red)); | |||
| } | |||
| } finally { | |||
| if (mounted) { | |||
| setState(() => _isImproving = false); | |||
| } | |||
| } | |||
| } | |||
| // --- CORRECTION 4 : NAVIGATION PROPRE VERS L'APERÇU FINAL --- | |||
| // --- CORRECTION APPLIQUÉE ICI --- | |||
| void _navigateToPreview() { | |||
| Navigator.pushNamed( | |||
| context, | |||
| AppRoutes.postPreview, | |||
| // Au lieu d'envoyer une Map, on crée l'objet PostPreviewArguments | |||
| // que l'écran suivant attend. | |||
| arguments: PostPreviewArguments( | |||
| imageBase64: widget.arguments.imageBase64, | |||
| text: _postTextController.text, | |||
| aiRepository: widget.arguments.aiRepository, | |||
| ), | |||
| ); | |||
| } | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| // Le widget build est INCHANGÉ dans sa structure. | |||
| return CreationFlowLayout( // Ajout du layout de flux | |||
| currentStep: 5, // C'est la 6ème étape | |||
| title: "5. Affinage du texte", | |||
| child: Scaffold( | |||
| appBar: AppBar( | |||
| title: const Text('Affiner le post'), | |||
| actions: [ | |||
| FilledButton.tonal( | |||
| onPressed: _navigateToPreview, | |||
| child: const Text('Valider'), | |||
| ), | |||
| const SizedBox(width: 16), | |||
| ], | |||
| ), | |||
| body: SingleChildScrollView( | |||
| padding: const EdgeInsets.all(16.0), | |||
| child: Column( | |||
| crossAxisAlignment: CrossAxisAlignment.stretch, | |||
| children: [ | |||
| TextField( | |||
| controller: _postTextController, | |||
| maxLines: 8, | |||
| decoration: const InputDecoration( | |||
| labelText: 'Texte du post', | |||
| border: OutlineInputBorder(), | |||
| alignLabelWithHint: true, | |||
| ), | |||
| ), | |||
| const SizedBox(height: 24), | |||
| const Row(children: [ | |||
| Expanded(child: Divider()), | |||
| Padding( | |||
| padding: EdgeInsets.symmetric(horizontal: 8.0), | |||
| child: Text("Améliorer avec l'IA"), | |||
| ), | |||
| Expanded(child: Divider()), | |||
| ]), | |||
| const SizedBox(height: 16), | |||
| TextField( | |||
| controller: _promptController, | |||
| decoration: InputDecoration( | |||
| hintText: "Ex: ajoute plus de détails, rends-le plus fun...", | |||
| border: const OutlineInputBorder(), | |||
| ), | |||
| ), | |||
| const SizedBox(height: 16), | |||
| FilledButton.icon( | |||
| onPressed: _handleImproveWithAI, | |||
| icon: _isImproving | |||
| ? const SizedBox( | |||
| width: 20, | |||
| height: 20, | |||
| child: CircularProgressIndicator( | |||
| color: Colors.white, strokeWidth: 2)) | |||
| : const Icon(Icons.auto_awesome), | |||
| label: const Text("Lancer l'amélioration"), | |||
| style: FilledButton.styleFrom( | |||
| padding: const EdgeInsets.symmetric(vertical: 16)), | |||
| ), | |||
| ], | |||
| ), | |||
| ), | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,206 @@ | |||
| import 'package:flutter/material.dart'; | |||
| import '../../../core/theme/colors.dart'; | |||
| import '../../../data/models/user_profile.dart'; | |||
| // L'import des routes n'est plus nécessaire ici pour la navigation, mais on le garde pour la propreté. | |||
| import 'package:social_content_creator/routes/app_routes.dart'; | |||
| final class ProfileSetupScreen extends StatefulWidget { | |||
| const ProfileSetupScreen({super.key}); | |||
| @override | |||
| State<ProfileSetupScreen> createState() => _ProfileSetupScreenState(); | |||
| } | |||
| class _ProfileSetupScreenState extends State<ProfileSetupScreen> { | |||
| final List<UserProfile> _profiles = []; | |||
| @override | |||
| void initState() { | |||
| super.initState(); | |||
| _addProfile(); | |||
| } | |||
| void _addProfile() { | |||
| setState(() { | |||
| _profiles.add( | |||
| const UserProfile( | |||
| profession: '', | |||
| tone: MessageTone.normal, | |||
| textStyle: TextStyleEnum.classic, | |||
| ), | |||
| ); | |||
| }); | |||
| } | |||
| void _updateProfile(int index, UserProfile profile) { | |||
| setState(() => _profiles[index] = profile); | |||
| } | |||
| void _removeProfile(int index) { | |||
| if (_profiles.length > 1) { | |||
| setState(() => _profiles.removeAt(index)); | |||
| } | |||
| } | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| return Scaffold( | |||
| appBar: AppBar(title: const Text('Créer votre profil')), | |||
| body: SafeArea( | |||
| child: Column( | |||
| children: [ | |||
| Expanded( | |||
| child: SingleChildScrollView( | |||
| padding: const EdgeInsets.all(20), | |||
| child: Column( | |||
| crossAxisAlignment: CrossAxisAlignment.start, | |||
| children: [ | |||
| Text( | |||
| 'Configurez vos profils', | |||
| style: Theme.of(context).textTheme.headlineSmall, | |||
| ), | |||
| const SizedBox(height: 24), | |||
| ..._profiles.asMap().entries.map((e) { | |||
| final (index, profile) = (e.key, e.value); | |||
| return _ProfileCard( | |||
| profile: profile, | |||
| onUpdate: (p) => _updateProfile(index, p), | |||
| onRemove: _profiles.length > 1 | |||
| ? () => _removeProfile(index) | |||
| : null, | |||
| index: index, | |||
| ); | |||
| }), | |||
| if (_profiles.length < 5) ...[ | |||
| const SizedBox(height: 16), | |||
| OutlinedButton.icon( | |||
| onPressed: _addProfile, | |||
| icon: const Icon(Icons.add), | |||
| label: const Text('Ajouter un profil'), | |||
| ), | |||
| ], | |||
| ], | |||
| ), | |||
| ), | |||
| ), | |||
| Container( | |||
| padding: const EdgeInsets.all(20), | |||
| child: SizedBox( | |||
| width: double.infinity, | |||
| child: FilledButton( | |||
| onPressed: _profiles.any((p) => p.profession.isNotEmpty) | |||
| ? () { | |||
| // --- CORRECTION --- | |||
| // 1. Logique pour sauvegarder les profils (à ajouter si nécessaire) | |||
| // | |||
| // 2. On ferme simplement l'écran de configuration pour revenir | |||
| // à l'écran qui l'a appelé (HomeScreen). | |||
| Navigator.pop(context); | |||
| } | |||
| : null, | |||
| child: const Text('Enregistrer et Terminer'), | |||
| ), | |||
| ), | |||
| ), | |||
| ], | |||
| ), | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| // ... Le widget _ProfileCard reste inchangé ... | |||
| class _ProfileCard extends StatefulWidget { | |||
| final UserProfile profile; | |||
| final Function(UserProfile) onUpdate; | |||
| final VoidCallback? onRemove; | |||
| final int index; | |||
| const _ProfileCard({ | |||
| required this.profile, | |||
| required this.onUpdate, | |||
| this.onRemove, | |||
| required this.index, | |||
| }); | |||
| @override | |||
| State<_ProfileCard> createState() => _ProfileCardState(); | |||
| } | |||
| class _ProfileCardState extends State<_ProfileCard> { | |||
| late final TextEditingController _controller; | |||
| @override | |||
| void initState() { | |||
| super.initState(); | |||
| _controller = TextEditingController(text: widget.profile.profession); | |||
| } | |||
| @override | |||
| void didUpdateWidget(_ProfileCard oldWidget) { | |||
| super.didUpdateWidget(oldWidget); | |||
| if (widget.profile.profession != _controller.text) { | |||
| _controller.text = widget.profile.profession; | |||
| } | |||
| } | |||
| @override | |||
| void dispose() { | |||
| _controller.dispose(); | |||
| super.dispose(); | |||
| } | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| return Card( | |||
| margin: const EdgeInsets.only(bottom: 16), | |||
| child: Padding( | |||
| padding: const EdgeInsets.all(16), | |||
| child: Column( | |||
| crossAxisAlignment: CrossAxisAlignment.start, | |||
| children: [ | |||
| Row( | |||
| mainAxisAlignment: MainAxisAlignment.spaceBetween, | |||
| children: [ | |||
| Text('Profil ${widget.index + 1}'), | |||
| if (widget.onRemove != null) | |||
| IconButton(icon: const Icon(Icons.close), onPressed: widget.onRemove), | |||
| ], | |||
| ), | |||
| const SizedBox(height: 16), | |||
| TextField( | |||
| controller: _controller, | |||
| decoration: const InputDecoration(labelText: 'Métier'), | |||
| onChanged: (v) => widget.onUpdate(widget.profile.copyWith(profession: v)), | |||
| ), | |||
| const SizedBox(height: 16), | |||
| Text('Ton:', style: Theme.of(context).textTheme.bodySmall), | |||
| Wrap( | |||
| spacing: 8, | |||
| children: MessageTone.values.map((t) { | |||
| return FilterChip( | |||
| label: Text(t.displayName), | |||
| selected: widget.profile.tone == t, | |||
| onSelected: (_) => widget.onUpdate(widget.profile.copyWith(tone: t)), | |||
| ); | |||
| }).toList(), | |||
| ), | |||
| const SizedBox(height: 16), | |||
| Text('Style:', style: Theme.of(context).textTheme.bodySmall), | |||
| Wrap( | |||
| spacing: 8, | |||
| children: TextStyleEnum.values.map((s) { | |||
| return FilterChip( | |||
| label: Text(s.displayName), | |||
| selected: widget.profile.textStyle == s, | |||
| onSelected: (_) => widget.onUpdate(widget.profile.copyWith(textStyle: s)), | |||
| ); | |||
| }).toList(), | |||
| ), | |||
| ], | |||
| ), | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,267 @@ | |||
| // lib/presentation/screens/text_generation/text_generation_screen.dart | |||
| import 'dart:convert'; | |||
| import 'package:flutter/material.dart';// --- CORRECTION 1 : IMPORTER LE REPOSITORY --- | |||
| import '../../../repositories/ai_repository.dart'; | |||
| import '../../../routes/app_routes.dart'; | |||
| import '../../widgets/creation_flow_layout.dart'; | |||
| import '../post_refinement/post_refinement_screen.dart'; // Import pour la classe d'arguments | |||
| // --- CORRECTION 2 : DÉFINIR UNE CLASSE D'ARGUMENTS PROPRE --- | |||
| class TextGenerationScreenArguments { | |||
| final String imageBase64; | |||
| final AiRepository aiRepository; | |||
| TextGenerationScreenArguments({ | |||
| required this.imageBase64, | |||
| required this.aiRepository, | |||
| }); | |||
| } | |||
| final class TextGenerationScreen extends StatefulWidget { | |||
| // Le constructeur attend maintenant la classe d'arguments. | |||
| final TextGenerationScreenArguments arguments; | |||
| const TextGenerationScreen({super.key, required this.arguments}); | |||
| @override | |||
| State<TextGenerationScreen> createState() => _TextGenerationScreenState(); | |||
| } | |||
| class _TextGenerationScreenState extends State<TextGenerationScreen> { | |||
| // Les variables d'état et contrôleurs restent inchangés | |||
| bool _loading = false; | |||
| List<String> _generatedIdeas = []; | |||
| final List<String> _logs = []; | |||
| final _professionController = TextEditingController(text: 'Entrepreneur digital'); | |||
| final _toneController = TextEditingController(text: 'Professionnel et engageant'); | |||
| @override | |||
| void initState() { | |||
| super.initState(); | |||
| _addLog("🖼️ Image reçue avec succès."); | |||
| } | |||
| void _addLog(String log) { | |||
| if (mounted) { | |||
| setState(() => _logs.insert(0, log)); | |||
| } | |||
| } | |||
| @override | |||
| void dispose() { | |||
| _professionController.dispose(); | |||
| _toneController.dispose(); | |||
| super.dispose(); | |||
| } | |||
| // --- CORRECTION 3 : UTILISER LE REPOSITORY POUR LA GÉNÉRATION --- | |||
| Future<void> _handleGenerateIdeas() async { | |||
| if (_loading) { | |||
| _addLog("⏳ Annulation : Génération déjà en cours."); | |||
| return; | |||
| } | |||
| if (!mounted) return; | |||
| setState(() { | |||
| _loading = true; | |||
| _generatedIdeas = []; | |||
| _logs.clear(); | |||
| }); | |||
| _addLog("▶️ Lancement de la génération d'idées..."); | |||
| try { | |||
| final profession = _professionController.text; | |||
| final tone = _toneController.text; | |||
| _addLog("⚙️ Paramètres : Métier='${profession}', Ton='${tone}'."); | |||
| _addLog("🚀 Appel du AiRepository..."); | |||
| // On appelle la méthode du Repository, qui délègue au bon service. | |||
| final ideas = await widget.arguments.aiRepository.generatePostIdeas( | |||
| base64Image: widget.arguments.imageBase64, | |||
| profession: profession, | |||
| tone: tone, | |||
| ); | |||
| if (!mounted) return; | |||
| _addLog("✅ Succès ! Réponse reçue."); | |||
| if (ideas.isEmpty) { | |||
| _addLog("⚠️ Avertissement : Le service a retourné une liste vide."); | |||
| } else { | |||
| _addLog("📦 ${ideas.length} idée(s) reçue(s)."); | |||
| } | |||
| setState(() { | |||
| _generatedIdeas = ideas; | |||
| }); | |||
| _addLog("✨ Interface mise à jour."); | |||
| } catch (e) { | |||
| if (!mounted) return; | |||
| _addLog("❌ ERREUR : ${e.toString()}"); | |||
| ScaffoldMessenger.of(context).showSnackBar(SnackBar( | |||
| content: Text('Erreur: ${e.toString()}'), | |||
| backgroundColor: Colors.red)); | |||
| } finally { | |||
| if (mounted) { | |||
| setState(() => _loading = false); | |||
| } | |||
| _addLog("⏹️ Fin du processus."); | |||
| } | |||
| } | |||
| // --- CORRECTION 4 : NAVIGATION PROPRE VERS L'ÉCRAN D'AFFINAGE --- | |||
| void _navigateToRefinementScreen(String selectedIdea) { | |||
| Navigator.pushNamed( | |||
| context, | |||
| AppRoutes.postRefinement, | |||
| arguments: PostRefinementScreenArguments( | |||
| initialText: selectedIdea, | |||
| imageBase64: widget.arguments.imageBase64, | |||
| aiRepository: widget.arguments.aiRepository, // On passe le Repository | |||
| ), | |||
| ); | |||
| } | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| // La structure du build reste la même, mais je corrige l'index de l'étape. | |||
| return CreationFlowLayout( | |||
| currentStep: 4, // C'est la 5ème étape (index 4) | |||
| title: '4. Génération de Texte', | |||
| child: Stack( | |||
| children: [ | |||
| SingleChildScrollView( | |||
| padding: const EdgeInsets.all(16.0), | |||
| child: Column( | |||
| crossAxisAlignment: CrossAxisAlignment.start, | |||
| children: [ | |||
| Center( | |||
| child: ConstrainedBox( | |||
| constraints: const BoxConstraints(maxHeight: 200), | |||
| child: ClipRRect( | |||
| borderRadius: BorderRadius.circular(12.0), | |||
| child: Image.memory( | |||
| base64Decode(widget.arguments.imageBase64), | |||
| width: double.infinity, | |||
| fit: BoxFit.cover, | |||
| ), | |||
| ), | |||
| ), | |||
| ), | |||
| const SizedBox(height: 24), | |||
| TextField( | |||
| controller: _professionController, | |||
| decoration: const InputDecoration( | |||
| labelText: 'Votre métier', | |||
| border: OutlineInputBorder(), | |||
| prefixIcon: Icon(Icons.work_outline))), | |||
| const SizedBox(height: 16), | |||
| TextField( | |||
| controller: _toneController, | |||
| decoration: const InputDecoration( | |||
| labelText: 'Ton souhaité', | |||
| border: OutlineInputBorder(), | |||
| prefixIcon: Icon(Icons.campaign_outlined))), | |||
| const SizedBox(height: 24), | |||
| SizedBox( | |||
| width: double.infinity, | |||
| child: FilledButton.icon( | |||
| onPressed: _loading ? null : _handleGenerateIdeas, | |||
| icon: _loading | |||
| ? const SizedBox( | |||
| width: 20, | |||
| height: 20, | |||
| child: CircularProgressIndicator( | |||
| color: Colors.white, strokeWidth: 2)) | |||
| : const Icon(Icons.auto_awesome), | |||
| label: const Text('Générer 3 idées de post'), | |||
| style: FilledButton.styleFrom( | |||
| padding: const EdgeInsets.symmetric(vertical: 16)), | |||
| ), | |||
| ), | |||
| const SizedBox(height: 24), | |||
| if (_generatedIdeas.isNotEmpty) ...[ | |||
| const Divider(height: 32), | |||
| Text("Choisissez une idée à affiner", | |||
| style: Theme.of(context) | |||
| .textTheme | |||
| .titleMedium | |||
| ?.copyWith(fontWeight: FontWeight.bold)), | |||
| const SizedBox(height: 16), | |||
| ListView.builder( | |||
| shrinkWrap: true, | |||
| physics: const NeverScrollableScrollPhysics(), | |||
| itemCount: _generatedIdeas.length, | |||
| itemBuilder: (context, index) { | |||
| final idea = _generatedIdeas[index]; | |||
| return Card( | |||
| margin: const EdgeInsets.only(bottom: 12), | |||
| child: ListTile( | |||
| title: Text(idea, | |||
| maxLines: 3, | |||
| overflow: TextOverflow.ellipsis), | |||
| leading: | |||
| CircleAvatar(child: Text('${index + 1}')), | |||
| trailing: const Icon(Icons.edit_note), | |||
| onTap: () => _navigateToRefinementScreen(idea))); | |||
| }, | |||
| ), | |||
| ], | |||
| const SizedBox(height: 150), // Espace pour la console de logs | |||
| ], | |||
| ), | |||
| ), | |||
| // La console de logs reste inchangée | |||
| Positioned( | |||
| bottom: 0, | |||
| left: 0, | |||
| right: 0, | |||
| child: IgnorePointer( | |||
| child: Container( | |||
| height: 140, | |||
| padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), | |||
| decoration: BoxDecoration( | |||
| gradient: LinearGradient( | |||
| colors: [ | |||
| Theme.of(context).scaffoldBackgroundColor.withOpacity(0.0), | |||
| Theme.of(context).scaffoldBackgroundColor.withOpacity(0.6), | |||
| Theme.of(context).scaffoldBackgroundColor, | |||
| ], | |||
| begin: Alignment.topCenter, | |||
| end: Alignment.bottomCenter, | |||
| ), | |||
| ), | |||
| child: ListView.builder( | |||
| reverse: true, | |||
| itemCount: _logs.length, | |||
| itemBuilder: (context, index) { | |||
| final log = _logs[index]; | |||
| return Text(log, style: TextStyle( | |||
| // Logique de couleur conditionnelle pour la lisibilité | |||
| color: log.contains('❌') | |||
| ? Theme.of(context).colorScheme.error // Rouge pour les erreurs | |||
| : (log.contains('✅') || log.contains('📦') || log.contains('✨')) | |||
| ? Colors.green[600] // Vert pour les succès | |||
| : Theme.of(context) | |||
| .colorScheme | |||
| .onSurface | |||
| .withOpacity(0.8), // Couleur par défaut | |||
| fontSize: 12 | |||
| ), | |||
| ); | |||
| }, | |||
| ), | |||
| ), | |||
| ), | |||
| ), | |||
| ], | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,108 @@ | |||
| // lib/presentation/widgets/creation_flow_layout.dart | |||
| import 'package:flutter/material.dart'; | |||
| class CreationProgressIndicator extends StatelessWidget { | |||
| final int currentStep; | |||
| final int totalSteps; | |||
| const CreationProgressIndicator({ super.key, | |||
| required this.currentStep, | |||
| this.totalSteps = 3, | |||
| }); | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| return Padding( | |||
| padding: const EdgeInsets.symmetric(vertical: 16.0), | |||
| // --- MODIFICATION 1 : On centre la Row principale --- | |||
| child: Center( | |||
| child: Row( | |||
| // On s'assure que la Row ne prend que la place nécessaire | |||
| mainAxisSize: MainAxisSize.min, | |||
| children: List.generate(totalSteps, (index) { | |||
| bool isActive = index == currentStep; | |||
| bool isCompleted = index < currentStep; | |||
| bool isLast = index == totalSteps - 1; | |||
| // --- MODIFICATION 2 : Chaque segment a maintenant une largeur fixe --- | |||
| return Row( | |||
| children: [ | |||
| // Le cercle de l'étape | |||
| Container( | |||
| height: 24, | |||
| width: 24, | |||
| decoration: BoxDecoration( | |||
| shape: BoxShape.circle, | |||
| color: isActive || isCompleted | |||
| ? Theme.of(context).primaryColor | |||
| : Theme.of(context).colorScheme.surfaceVariant, | |||
| ), | |||
| child: Center( | |||
| child: isCompleted | |||
| ? const Icon(Icons.check, color: Colors.white, size: 16) | |||
| : Text( | |||
| '${index + 1}', | |||
| style: TextStyle( | |||
| color: isActive | |||
| ? Colors.white | |||
| : Theme.of(context).colorScheme.onSurfaceVariant, | |||
| fontWeight: FontWeight.bold, | |||
| ), | |||
| ), | |||
| ), | |||
| ), | |||
| // La ligne de connexion (sauf pour la dernière) | |||
| if (!isLast) | |||
| Container( | |||
| // On donne une largeur fixe à la ligne de connexion | |||
| width: 60, | |||
| height: 2, | |||
| margin: const EdgeInsets.symmetric(horizontal: 8.0), | |||
| color: isCompleted | |||
| ? Theme.of(context).primaryColor | |||
| : Theme.of(context).colorScheme.surfaceVariant, | |||
| ), | |||
| ], | |||
| ); | |||
| }), | |||
| ), | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| // Le reste du fichier CreationFlowLayout reste inchangé | |||
| class CreationFlowLayout extends StatelessWidget { | |||
| final int currentStep; | |||
| final String title; | |||
| final Widget child; | |||
| const CreationFlowLayout({ | |||
| super.key, | |||
| required this.currentStep, | |||
| required this.title, | |||
| required this.child, | |||
| }); | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| return Scaffold( | |||
| appBar: AppBar( | |||
| title: Text(title), | |||
| elevation: 0, | |||
| centerTitle: true, | |||
| ), | |||
| body: Column( | |||
| children: [ | |||
| CreationProgressIndicator(currentStep: currentStep), | |||
| const Divider(height: 1), | |||
| Expanded( | |||
| child: child, | |||
| ), | |||
| ], | |||
| ), | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,94 @@ | |||
| // lib/repositories/ai_repository.dart | |||
| // 1. IMPORTER LES INTERFACES ET LES IMPLÉMENTATIONS NÉCESSAIRES | |||
| // Le fichier ollama_service.dart contient nos nouvelles classes découpées. | |||
| import '../services/ollama_service.dart'; | |||
| import '../services/stable_diffusion_service.dart'; | |||
| import '../services/gemini_service.dart'; | |||
| import '../services/image_editing_service.dart'; | |||
| // Énumération pour choisir le modèle de génération d'image. | |||
| enum ImageGenerationModel { stableDiffusion, gemini } | |||
| /// Le AiRepository est le point d'entrée centralisé pour toutes les opérations d'IA. | |||
| /// L'interface utilisateur ne parlera qu'à ce Repository. | |||
| class AiRepository { | |||
| // --- NOS SERVICES SPÉCIALISÉS --- | |||
| // Pour l'analyse initiale de l'image ("prompt 1"). | |||
| // On utilise l'implémentation Ollama, mais on ne dépend que de l'interface. | |||
| final ImageAnalysisService _imageAnalyzer = OllamaImageAnalysisService(); | |||
| // Pour générer les idées de posts. | |||
| final PostCreationService _postCreator = OllamaPostCreationService(); | |||
| // Pour améliorer le texte. | |||
| final TextImprovementService _textImprover = OllamaTextImprovementService(); | |||
| // Une collection de services pour la GÉNÉRATION D'IMAGES. | |||
| final Map<ImageGenerationModel, ImageEditingService> _imageGenerators; | |||
| // Le constructeur initialise la collection de générateurs d'images. | |||
| AiRepository() | |||
| : _imageGenerators = { | |||
| ImageGenerationModel.stableDiffusion: StableDiffusionService(), | |||
| ImageGenerationModel.gemini: GeminiService(), | |||
| }; | |||
| // --- LES MÉTHODES PUBLIQUES UTILISÉES PAR L'INTERFACE UTILISATEUR --- | |||
| /// Étape: `MediaPickerScreen` -> `AiEnhancementScreen` | |||
| /// Analyse l'image pour obtenir une description textuelle (le "prompt 1"). | |||
| /// C'est la tâche qui est lancée "en amont". | |||
| Future<String> analyzeImageForPrompt(String base64Image) { | |||
| print("[AiRepository] Délégation de l'analyse de l'image à l'ImageAnalyzer..."); | |||
| return _imageAnalyzer.analyzeImage(base64Image); | |||
| } | |||
| /// Étape: `AiEnhancementScreen` | |||
| /// Génère de nouvelles versions d'une image en utilisant un modèle spécifique. | |||
| Stream<String> generateImageVariations({ | |||
| required ImageGenerationModel model, | |||
| required String base64Image, | |||
| required String prompt, | |||
| required int width, | |||
| required int height, | |||
| int numberOfImages = 1, | |||
| }) { | |||
| print("[AiRepository] Délégation de la génération d'images au modèle : $model"); | |||
| final generator = _imageGenerators[model]; | |||
| if (generator == null) { | |||
| // Retourne un stream avec une erreur si le modèle n'est pas configuré. | |||
| return Stream.error(Exception("Le modèle de génération d'image '$model' n'est pas disponible.")); | |||
| } | |||
| return generator.editImage(base64Image, prompt, width, height, numberOfImages: numberOfImages); | |||
| } | |||
| /// Étape: `TextGenerationScreen` | |||
| /// Génère des idées de posts basées sur l'image et un profil utilisateur. | |||
| Future<List<String>> generatePostIdeas({ | |||
| required String base64Image, | |||
| required String profession, | |||
| required String tone, | |||
| }) { | |||
| print("[AiRepository] Délégation de la création de posts au PostCreator..."); | |||
| return _postCreator.generatePostIdeas( | |||
| base64Image: base64Image, | |||
| profession: profession, | |||
| tone: tone, | |||
| ); | |||
| } | |||
| /// Étape: `PostRefinementScreen` | |||
| /// Améliore un texte existant selon une instruction. | |||
| Future<String> improvePostText({ | |||
| required String originalText, | |||
| required String userInstruction, | |||
| }) { | |||
| print("[AiRepository] Délégation de l'amélioration du texte au TextImprover..."); | |||
| return _textImprover.improveText( | |||
| originalText: originalText, | |||
| userInstruction: userInstruction, | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,30 @@ | |||
| // lib/routes/app_routes.dart | |||
| // Il n'est plus nécessaire d'importer les écrans ici | |||
| /// Contient les noms des routes de l'application sous forme de constantes statiques. | |||
| abstract final class AppRoutes { | |||
| static const String profileSetup = '/profile-setup'; | |||
| static const String mediaPicker = '/media-picker'; | |||
| static const String aiEnhancement = '/ai-enhancement'; | |||
| static const String textGeneration = '/text-generation'; | |||
| static const String postPreview = '/post-preview'; | |||
| static const String postRefinement = '/post-refinement'; // <-- Route conservée | |||
| static const String export = '/export'; | |||
| static const String imagePreview = '/image-preview'; | |||
| static const String login = '/login'; | |||
| static const String home = '/home'; | |||
| // Ce bloc n'est plus utilisé car on utilise onGenerateRoute dans main.dart | |||
| // Vous pouvez le supprimer complètement pour nettoyer le code. | |||
| /* | |||
| static Map<String, WidgetBuilder> get routes => { | |||
| mediaPicker: (_) => const MediaPickerScreen(), | |||
| profileSetup: (_) => const ProfileSetupScreen(), | |||
| aiEnhancement: (_) => const AiEnhancementScreen(), | |||
| textGeneration: (_) => const TextGenerationScreen(), | |||
| postPreview: (_) => const PostPreviewScreen(), | |||
| export: (_) => const ExportScreen(), | |||
| imagePreview: (_) => const ImagePreviewScreen(), | |||
| }; | |||
| */ | |||
| } | |||
| @@ -0,0 +1,250 @@ | |||
| // lib/services/gemini_service.dart | |||
| import 'dart:async';import 'dart:convert'; | |||
| import 'package:http/http.dart' as http; | |||
| import 'image_editing_service.dart'; | |||
| class GeminiService implements ImageEditingService { | |||
| final String _apiKey; | |||
| // L'URL que vous utilisiez et qui fonctionnait | |||
| final String _apiUrl = | |||
| 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent'; | |||
| GeminiService({String? apiKey}) : _apiKey = apiKey ?? 'AIzaSyBQpeyl-Qi8QoCfwmJoxAumYYI5-nwit4Q'; // Remplacez par votre clé | |||
| /// EditImage qui retourne un Stream, comme attendu par l'interface. | |||
| @override | |||
| Stream<String> editImage(String base64Image, | |||
| String prompt, | |||
| int width, | |||
| int height, | |||
| {int numberOfImages = 1}) { | |||
| // 1. On crée un StreamController | |||
| final controller = StreamController<String>(); | |||
| // 2. On lance la génération en arrière-plan | |||
| _generateAndStreamImage(controller, base64Image, prompt, width, height); | |||
| // 3. On retourne le stream immédiatement | |||
| return controller.stream; | |||
| } | |||
| /// Méthode privée qui contient la logique de génération correcte. | |||
| Future<void> _generateAndStreamImage(StreamController<String> controller, | |||
| String base64Image, | |||
| String prompt, | |||
| int width, | |||
| int height) async { | |||
| print("[GeminiService] 🚀 Lancement de la génération d'image..."); | |||
| final editPrompt = _buildEditPrompt(prompt, width, height); | |||
| final requestBody = { | |||
| 'contents': [ | |||
| { | |||
| 'parts': [ | |||
| {'text': editPrompt}, | |||
| { | |||
| 'inlineData': { | |||
| 'mimeType': 'image/jpeg', | |||
| 'data': base64Image, | |||
| } | |||
| } | |||
| ] | |||
| } | |||
| ], | |||
| 'generationConfig': { | |||
| // La documentation la plus récente suggère d'utiliser 'tool_config' pour ce genre de tâche | |||
| // mais nous allons garder la version qui marchait pour vous. | |||
| // Si une erreur survient, ce sera le premier endroit à vérifier. | |||
| // 👇👇👇 CETTE LIGNE EST LA CLÉ DU SUCCÈS 👇👇👇 | |||
| 'responseModalities': ['IMAGE', 'TEXT'], | |||
| } | |||
| }; | |||
| try { | |||
| // --- CORRECTION DE L'URL --- | |||
| // L'action ':generateContent' est déjà dans la variable _apiUrl. | |||
| final response = await http | |||
| .post( | |||
| Uri.parse('$_apiUrl?key=$_apiKey'), | |||
| headers: {'Content-Type': 'application/json'}, | |||
| body: jsonEncode(requestBody), | |||
| ) | |||
| .timeout(const Duration(minutes: 5)); | |||
| if (response.statusCode == 200) { | |||
| final responseData = jsonDecode(response.body); | |||
| final String singleImage = _extractImageFromResponse(responseData); | |||
| print("[GeminiService] ✅ Image reçue. Envoi dans le stream..."); | |||
| controller.add(singleImage); | |||
| } else { | |||
| final errorBody = jsonDecode(response.body); | |||
| final errorMessage = errorBody['error']?['message'] ?? response.body; | |||
| throw Exception('Erreur Gemini ${response.statusCode}: $errorMessage'); | |||
| } | |||
| } catch (e, s) { | |||
| print("[GeminiService] ❌ Erreur lors de la génération: $e"); | |||
| controller.addError(e, s); | |||
| } finally { | |||
| print("[GeminiService] Stream fermé."); | |||
| controller.close(); | |||
| } | |||
| } | |||
| // --- TOUTES LES MÉTHODES HELPER SONT MAINTENANT À L'INTÉRIEUR DE LA CLASSE --- | |||
| String _buildEditPrompt(String userPrompt, int width, int height) { | |||
| return '''You are a professional photo editor. Based on the provided image and instructions below, | |||
| generate an edited version that enhances the photo while maintaining naturalness and authenticity. | |||
| INSTRUCTIONS: | |||
| $userPrompt | |||
| EDITING GUIDELINES: | |||
| - Preserve the original composition and subject | |||
| - Maintain realistic skin textures (no plastic look) | |||
| - Enhance lighting naturally with warm, soft tones | |||
| - Improve colors while keeping them true-to-life | |||
| - Add subtle details and clarity without over-processing | |||
| - Keep the image aspect ratio and dimensions | |||
| - Ensure the final result looks professional and Instagram-ready | |||
| CONSTRAINTS: | |||
| - Do NOT add watermarks or text | |||
| - Do NOT change the main subject dramatically | |||
| - Do NOT apply artificial filters or effects | |||
| - Keep editing subtle and professional | |||
| Generate the edited image following these guidelines.'''; | |||
| } | |||
| // --- MÉTHODE D'EXTRACTION CORRIGÉE --- | |||
| String _extractImageFromResponse(Map<String, dynamic> response) { | |||
| try { | |||
| final candidates = response['candidates'] as List<dynamic>?; | |||
| if (candidates == null || candidates.isEmpty) { | |||
| // Si il n'y a aucun candidat, c'est que le prompt a été bloqué AVANT la génération. | |||
| final promptFeedback = response['promptFeedback']; | |||
| if (promptFeedback != null && promptFeedback['blockReason'] != null) { | |||
| throw Exception("Le prompt a été bloqué par Gemini pour la raison : ${promptFeedback['blockReason']}."); | |||
| } | |||
| throw Exception('Réponse invalide de Gemini : aucun "candidate" trouvé.'); | |||
| } | |||
| final candidate = candidates.first; | |||
| // 1. Vérifier la raison de la fin AVANT de chercher le contenu. | |||
| final finishReason = candidate['finishReason'] as String?; | |||
| if (finishReason != null && finishReason != 'STOP') { | |||
| if (finishReason == 'SAFETY') { | |||
| throw Exception("La génération a été stoppée par Gemini pour des raisons de sécurité (SAFETY). L'image ou le prompt a été jugé inapproprié."); | |||
| } | |||
| throw Exception("La génération s'est terminée prématurément. Raison : $finishReason"); | |||
| } | |||
| // 2. Si tout va bien, on peut maintenant chercher le contenu. | |||
| final content = candidate['content'] as Map<String, dynamic>?; | |||
| if (content == null) { | |||
| throw Exception('Pas de "content" dans la première candidate, malgré un "finishReason" correct. Réponse inattendue.'); | |||
| } | |||
| final parts = content['parts'] as List<dynamic>?; | |||
| if (parts == null || parts.isEmpty) { | |||
| throw Exception('Pas de "parts" dans le content'); | |||
| } | |||
| for (final part in parts) { | |||
| final inlineData = part['inlineData'] as Map<String, dynamic>?; | |||
| if (inlineData != null && inlineData.containsKey('data')) { | |||
| return inlineData['data'] as String; | |||
| } | |||
| } | |||
| throw Exception("Pas d'image (inlineData) dans les parts de la réponse."); | |||
| } catch (e) { | |||
| // Afficher la réponse brute aide toujours à déboguer | |||
| print("--- Réponse brute de Gemini lors de l'erreur d'extraction ---"); | |||
| print(jsonEncode(response)); | |||
| print("------------------------------------------------------------"); | |||
| // Renvoie l'erreur spécifique que nous avons construite. | |||
| throw Exception('Erreur d\'extraction de l\'image: $e'); | |||
| } | |||
| } | |||
| @override | |||
| Future<String> generatePrompt(String base64Image) async { | |||
| print("[GeminiService] 🚀 Lancement de l'analyse d'image pour le prompt..."); | |||
| final requestBody = { | |||
| 'contents': [ | |||
| { | |||
| 'parts': [ | |||
| { | |||
| 'text': | |||
| 'Analyze this image and suggest specific professional photo enhancements and a good instagram publication text. Focus on: lighting, colors, composition, and overall aesthetic. Be concise and actionable.' | |||
| }, | |||
| { | |||
| 'inlineData': { | |||
| 'mimeType': 'image/jpeg', | |||
| 'data': base64Image, | |||
| } | |||
| } | |||
| ] | |||
| } | |||
| ] | |||
| }; | |||
| try { | |||
| final response = await http | |||
| .post( | |||
| Uri.parse('$_apiUrl?key=$_apiKey'), | |||
| headers: {'Content-Type': 'application/json'}, | |||
| body: jsonEncode(requestBody), | |||
| ) | |||
| .timeout(const Duration(minutes: 3)); | |||
| if (response.statusCode == 200) { | |||
| final responseData = jsonDecode(response.body); | |||
| return _extractTextFromResponse(responseData); | |||
| } else { | |||
| throw Exception('Erreur Gemini ${response.statusCode}: ${response.body}'); | |||
| } | |||
| } catch (e) { | |||
| throw Exception('Erreur génération prompt Gemini: $e'); | |||
| } | |||
| } | |||
| String _extractTextFromResponse(Map<String, dynamic> response) { | |||
| try { | |||
| final candidates = response['candidates'] as List<dynamic>?; | |||
| if (candidates == null || candidates.isEmpty) { | |||
| return ''; | |||
| } | |||
| final content = candidates[0]['content'] as Map<String, dynamic>?; | |||
| if (content == null) { | |||
| return ''; | |||
| } | |||
| final parts = content['parts'] as List<dynamic>?; | |||
| if (parts == null || parts.isEmpty) { | |||
| return ''; | |||
| } | |||
| final buffer = StringBuffer(); | |||
| for (final part in parts) { | |||
| final text = part['text'] as String?; | |||
| if (text != null && text.isNotEmpty) { | |||
| buffer.writeln(text.trim()); | |||
| } | |||
| } | |||
| return buffer.toString().trim(); | |||
| } catch (e) { | |||
| print('Erreur extraction texte: $e'); | |||
| return ''; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,57 @@ | |||
| import 'dart:convert'; | |||
| import 'package:http/http.dart' as http; | |||
| /// Définit un contrat pour tout service capable d'analyser une image | |||
| /// et de la décrire sous forme de texte. | |||
| abstract class ImageAnalysisService { | |||
| /// Prend une image en base64 et retourne une description textuelle (prompt). | |||
| Future<String> analyzeImage(String base64Image); | |||
| } | |||
| /// L'implémentation Ollama de ce service. | |||
| class OllamaImageAnalysisService implements ImageAnalysisService { | |||
| final String _apiUrl = 'http://192.168.20.200:11434/api/generate'; | |||
| final String _visionModel = 'llava:7b'; | |||
| @override | |||
| Future<String> analyzeImage(String base64Image) async { | |||
| print( | |||
| "[OllamaImageAnalysisService] 🚀 Lancement de l'analyse de l'image..."); | |||
| final requestPrompt = ''' | |||
| As a professional photographe and instagram, describe this imange and give ideas to improve quality to publish on social network. | |||
| '''; | |||
| final requestBody = { | |||
| 'model': _visionModel, | |||
| 'prompt': requestPrompt, | |||
| 'images': [base64Image], | |||
| 'stream': false, | |||
| }; | |||
| try { | |||
| final response = await http.post( | |||
| Uri.parse(_apiUrl), | |||
| headers: {'Content-Type': 'application/json'}, | |||
| body: jsonEncode(requestBody), | |||
| ).timeout(const Duration(minutes: 2)); | |||
| if (response.statusCode == 200) { | |||
| final body = jsonDecode(response.body); | |||
| final generatedPrompt = (body['response'] as String? ?? '') | |||
| .trim() | |||
| .replaceAll('\n', ' '); | |||
| print( | |||
| "[OllamaImageAnalysisService] ✅ Analyse terminée : $generatedPrompt"); | |||
| return generatedPrompt; | |||
| } else { | |||
| throw Exception( | |||
| 'Erreur Ollama (analyzeImage) ${response.statusCode}: ${response | |||
| .body}'); | |||
| } | |||
| } catch (e) { | |||
| print("[OllamaImageAnalysisService] ❌ Exception : ${e.toString()}"); | |||
| rethrow; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| // lib/services/image_editing_service.dart | |||
| abstract class ImageEditingService { | |||
| /// Génère un prompt descriptif à partir d'une image. | |||
| Future<String> generatePrompt(String base64Image); | |||
| /// Modifie une image en se basant sur un prompt. | |||
| Stream<String> editImage(String base64Image, String prompt, int width, int height, {int numberOfImages = 1}); | |||
| } | |||
| @@ -0,0 +1,196 @@ | |||
| // lib/services/ollama_service.dart | |||
| import 'dart:convert'; | |||
| import 'package:http/http.dart' as http; | |||
| // ========================================================================= | |||
| // SERVICE 1: ANALYSE D'IMAGE (POUR LE PROMPT INITIAL) | |||
| // ========================================================================= | |||
| /// Définit un contrat pour tout service capable d'analyser une image | |||
| /// et de la décrire sous forme de texte. | |||
| abstract class ImageAnalysisService { | |||
| /// Prend une image en base64 et retourne une description textuelle (prompt). | |||
| Future<String> analyzeImage(String base64Image); | |||
| } | |||
| /// L'implémentation Ollama de ce service. | |||
| class OllamaImageAnalysisService implements ImageAnalysisService { | |||
| final String _apiUrl = 'http://192.168.20.200:11434/api/generate'; | |||
| final String _visionModel = 'llava:7b'; | |||
| @override | |||
| Future<String> analyzeImage(String base64Image) async { | |||
| print("[OllamaImageAnalysisService] 🚀 Lancement de l'analyse de l'image..."); | |||
| final requestPrompt = ''' | |||
| Décris cette image en une phrase courte et factuelle, comme un prompt pour une IA. | |||
| Concentre-toi sur le sujet principal, son action et l'environnement. | |||
| Sois direct, concis et ne mentionne pas le mot 'image' ou 'photo'. | |||
| '''; | |||
| final requestBody = { | |||
| 'model': _visionModel, | |||
| 'prompt': requestPrompt, | |||
| 'images': [base64Image], | |||
| 'stream': false, | |||
| }; | |||
| try { | |||
| final response = await http.post( | |||
| Uri.parse(_apiUrl), | |||
| headers: {'Content-Type': 'application/json'}, | |||
| body: jsonEncode(requestBody), | |||
| ).timeout(const Duration(minutes: 2)); | |||
| if (response.statusCode == 200) { | |||
| final body = jsonDecode(response.body); | |||
| final generatedPrompt = (body['response'] as String? ?? '').trim().replaceAll('\n', ' '); | |||
| print("[OllamaImageAnalysisService] ✅ Analyse terminée : $generatedPrompt"); | |||
| return generatedPrompt; | |||
| } else { | |||
| throw Exception('Erreur Ollama (analyzeImage) ${response.statusCode}: ${response.body}'); | |||
| } | |||
| } catch (e) { | |||
| print("[OllamaImageAnalysisService] ❌ Exception : ${e.toString()}"); | |||
| rethrow; | |||
| } | |||
| } | |||
| } | |||
| // ========================================================================= | |||
| // SERVICE 2: CRÉATION DE POSTS SOCIAUX | |||
| // ========================================================================= | |||
| /// Définit un contrat pour tout service capable de générer des idées de posts. | |||
| abstract class PostCreationService { | |||
| Future<List<String>> generatePostIdeas({ | |||
| required String base64Image, | |||
| required String profession, | |||
| required String tone, | |||
| }); | |||
| } | |||
| /// L'implémentation Ollama de ce service. | |||
| class OllamaPostCreationService implements PostCreationService { | |||
| final String _apiUrl = 'http://192.168.20.200:11434/api/generate'; | |||
| final String _visionModel = 'llava:7b'; | |||
| @override | |||
| Future<List<String>> generatePostIdeas({ | |||
| required String base64Image, | |||
| required String profession, | |||
| required String tone, | |||
| }) async { | |||
| final requestPrompt = """ | |||
| You are a social media expert. | |||
| Act as a "$profession". | |||
| Analyze the image. | |||
| Generate 3 short and engaging social media post ideas in french with a "$tone" tone. | |||
| Your output MUST be a valid JSON array of strings. | |||
| Example: | |||
| ["Idée de post 1...", "Idée de post 2...", "Idée de post 3..."] | |||
| """; | |||
| final requestBody = { | |||
| 'model': _visionModel, | |||
| 'prompt': requestPrompt, | |||
| 'images': [base64Image], | |||
| 'stream': false, | |||
| }; | |||
| try { | |||
| print("[OllamaPostCreationService] 🚀 Appel pour générer des idées..."); | |||
| final response = await http.post( | |||
| Uri.parse(_apiUrl), | |||
| headers: {'Content-Type': 'application/json'}, | |||
| body: jsonEncode(requestBody), | |||
| ).timeout(const Duration(minutes: 3)); | |||
| if (response.statusCode == 200) { | |||
| final responseData = jsonDecode(response.body); | |||
| final jsonString = (responseData['response'] as String? ?? '').trim(); | |||
| if (jsonString.isEmpty) return []; | |||
| try { | |||
| final ideasList = jsonDecode(jsonString) as List; | |||
| return ideasList.map((idea) => idea.toString()).toList(); | |||
| } catch (e) { | |||
| print("[OllamaPostCreationService] ❌ Erreur de parsing JSON. Réponse : $jsonString"); | |||
| return [jsonString]; // Retourne la réponse brute comme une seule idée | |||
| } | |||
| } else { | |||
| throw Exception('Erreur Ollama (generatePostIdeas) ${response.statusCode}: ${response.body}'); | |||
| } | |||
| } catch (e) { | |||
| print("[OllamaPostCreationService] ❌ Exception : ${e.toString()}"); | |||
| rethrow; | |||
| } | |||
| } | |||
| } | |||
| // ========================================================================= | |||
| // SERVICE 3: AMÉLIORATION DE TEXTE | |||
| // ========================================================================= | |||
| /// Définit un contrat pour tout service capable d'améliorer un texte. | |||
| abstract class TextImprovementService { | |||
| Future<String> improveText({ | |||
| required String originalText, | |||
| required String userInstruction, | |||
| }); | |||
| } | |||
| /// L'implémentation Ollama de ce service. | |||
| class OllamaTextImprovementService implements TextImprovementService { | |||
| final String _apiUrl = 'http://192.168.20.200:11434/api/generate'; | |||
| final String _textModel = 'gpt-oss:20b'; | |||
| @override | |||
| Future<String> improveText({ | |||
| required String originalText, | |||
| required String userInstruction, | |||
| }) async { | |||
| print("[OllamaTextImprovementService] 🚀 Appel pour améliorer le texte..."); | |||
| final requestPrompt = """ | |||
| You are a social media writing assistant. | |||
| A user wants to improve the following text: | |||
| --- TEXT TO IMPROVE --- | |||
| $originalText | |||
| ----------------------- | |||
| The user's instruction is: "$userInstruction". | |||
| Rewrite the text based on the instruction. | |||
| Your output MUST be ONLY the improved text, without any extra commentary or explanations. | |||
| """; | |||
| final requestBody = { | |||
| 'model': _textModel, | |||
| 'prompt': requestPrompt, | |||
| 'stream': false, | |||
| }; | |||
| try { | |||
| final response = await http.post( | |||
| Uri.parse(_apiUrl), | |||
| headers: {'Content-Type': 'application/json'}, | |||
| body: jsonEncode(requestBody), | |||
| ).timeout(const Duration(minutes: 2)); | |||
| if (response.statusCode == 200) { | |||
| final responseData = jsonDecode(response.body); | |||
| return (responseData['response'] as String? ?? '').trim(); | |||
| } else { | |||
| throw Exception('Erreur Ollama (improveText) ${response.statusCode}: ${response.body}'); | |||
| } | |||
| } catch (e) { | |||
| print("[OllamaTextImprovementService] ❌ Exception : ${e.toString()}"); | |||
| rethrow; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,71 @@ | |||
| import 'dart:convert'; | |||
| import 'package:http/http.dart' as http; | |||
| /// Définit un contrat pour tout service capable de générer des idées de posts. | |||
| abstract class PostCreationService { | |||
| Future<List<String>> generatePostIdeas({ | |||
| required String base64Image, | |||
| required String profession, | |||
| required String tone, | |||
| }); | |||
| } | |||
| /// L'implémentation Ollama de ce service. | |||
| class OllamaPostCreationService implements PostCreationService { | |||
| final String _apiUrl = 'http://192.168.20.200:11434/api/generate'; | |||
| final String _visionModel = 'llava:7b'; | |||
| @override | |||
| Future<List<String>> generatePostIdeas({ | |||
| required String base64Image, | |||
| required String profession, | |||
| required String tone, | |||
| }) async { | |||
| final requestPrompt = """ | |||
| You are a social media expert. | |||
| Act as a "$profession". | |||
| Analyze the image. | |||
| Generate 3 short and engaging social media post ideas in french with a "$tone" tone. | |||
| Your output MUST be a valid JSON array of strings. | |||
| Example: | |||
| ["", "", ""] | |||
| """; | |||
| final requestBody = { | |||
| 'model': _visionModel, | |||
| 'prompt': requestPrompt, | |||
| 'images': [base64Image], | |||
| 'stream': false, | |||
| }; | |||
| try { | |||
| print("[OllamaPostCreationService] 🚀 Appel pour générer des idées..."); | |||
| final response = await http.post( | |||
| Uri.parse(_apiUrl), | |||
| headers: {'Content-Type': 'application/json'}, | |||
| body: jsonEncode(requestBody), | |||
| ).timeout(const Duration(minutes: 3)); | |||
| if (response.statusCode == 200) { | |||
| final responseData = jsonDecode(response.body); | |||
| final jsonString = (responseData['response'] as String? ?? '').trim(); | |||
| if (jsonString.isEmpty) return []; | |||
| try { | |||
| final ideasList = jsonDecode(jsonString) as List; | |||
| return ideasList.map((idea) => idea.toString()).toList(); | |||
| } catch (e) { | |||
| print("[OllamaPostCreationService] ❌ Erreur de parsing JSON. Réponse : $jsonString"); | |||
| return [jsonString]; // Retourne la réponse brute comme une seule idée | |||
| } | |||
| } else { | |||
| throw Exception('Erreur Ollama (generatePostIdeas) ${response.statusCode}: ${response.body}'); | |||
| } | |||
| } catch (e) { | |||
| print("[OllamaPostCreationService] ❌ Exception : ${e.toString()}"); | |||
| rethrow; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,138 @@ | |||
| // lib/services/stable_diffusion_service.dart | |||
| import 'dart:async'; | |||
| import 'dart:convert'; | |||
| import 'dart:typed_data'; | |||
| import 'package:flutter/foundation.dart'; | |||
| import 'package:http/http.dart' as http; | |||
| import 'package:image/image.dart' as img; | |||
| import 'image_editing_service.dart'; | |||
| // --- Définitions des filtres (isolés pour être utilisés avec compute) --- | |||
| // Look-up table pour les couleurs | |||
| typedef ColorLut = ({List<int> r, List<int> g, List<int> b}); | |||
| // Filtre 1: Applique un filtre chaud via une LUT | |||
| Uint8List _applyWarmthFilter((Uint8List, ColorLut) params) { | |||
| final imageBytes = params.$1; | |||
| final lut = params.$2; | |||
| final image = img.decodeImage(imageBytes); | |||
| if (image == null) return imageBytes; | |||
| for (final pixel in image) { | |||
| pixel.r = lut.r[pixel.r.toInt()]; | |||
| pixel.g = lut.g[pixel.g.toInt()]; | |||
| pixel.b = lut.b[pixel.b.toInt()]; | |||
| } | |||
| return Uint8List.fromList(img.encodeJpg(image, quality: 95)); | |||
| } | |||
| // Filtre 2: Augmente le contraste et la saturation | |||
| Uint8List _applyContrastSaturationFilter(Uint8List imageBytes) { | |||
| final image = img.decodeImage(imageBytes); | |||
| if (image == null) return imageBytes; | |||
| img.adjustColor(image, contrast: 1.2, saturation: 1.15); | |||
| return Uint8List.fromList(img.encodeJpg(image, quality: 95)); | |||
| } | |||
| // Calcule la LUT pour le filtre chaud | |||
| ColorLut _computeWarmthLut() { | |||
| final rLut = List.generate(256, (i) => (i * 1.15).clamp(0, 255).toInt()); | |||
| final gLut = List.generate(256, (i) => (i * 1.05).clamp(0, 255).toInt()); | |||
| final bLut = List.generate(256, (i) => (i * 0.90).clamp(0, 255).toInt()); | |||
| return (r: rLut, g: gLut, b: bLut); | |||
| } | |||
| class StableDiffusionService implements ImageEditingService { | |||
| final String _apiUrl = 'http://192.168.20.200:7860/sdapi/v1/img2img'; | |||
| @override | |||
| Future<String> generatePrompt(String base64Image) async { | |||
| throw UnimplementedError('Stable Diffusion ne peut pas générer de prompt.'); | |||
| } | |||
| @override | |||
| Stream<String> editImage(String base64Image, String prompt, int width, int height, {int numberOfImages = 3}) { | |||
| final controller = StreamController<String>(); | |||
| _generateVariations(controller, base64Image, prompt, width, height); | |||
| return controller.stream; | |||
| } | |||
| /// NOUVELLE LOGIQUE DE GÉNÉRATION, PLUS EFFICACE | |||
| Future<void> _generateVariations(StreamController<String> controller, String base64Image, String prompt, int width, int height) async { | |||
| try { | |||
| // 1. On fait UN SEUL appel à Stable Diffusion pour une variation de base. | |||
| final response = await _createImageRequest( | |||
| base64Image: base64Image, | |||
| prompt: "$prompt, high quality, sharp focus", // Prompt générique de qualité | |||
| width: width, | |||
| height: height, | |||
| denoisingStrength: 0.30, // Assez pour une variation notable | |||
| cfgScale: 7.0, | |||
| ); | |||
| if (response.statusCode != 200) { | |||
| throw Exception('Erreur Stable Diffusion ${response.statusCode}: ${response.body}'); | |||
| } | |||
| final responseData = jsonDecode(response.body); | |||
| final generatedImageBase64 = (responseData['images'] as List).first as String; | |||
| // La variation de base est notre première image. | |||
| controller.add(generatedImageBase64); | |||
| // 2. On prépare les filtres à appliquer sur cette variation de base. | |||
| final generatedImageBytes = base64Decode(generatedImageBase64); | |||
| final warmthLut = _computeWarmthLut(); | |||
| // 3. On lance les deux filtres en parallèle pour plus de rapidité. | |||
| final futureWarm = compute(_applyWarmthFilter, (generatedImageBytes, warmthLut)); | |||
| final futureContrast = compute(_applyContrastSaturationFilter, generatedImageBytes); | |||
| // 4. On attend les résultats des filtres et on les ajoute au stream. | |||
| final results = await Future.wait([futureWarm, futureContrast]); | |||
| for (final filteredBytes in results) { | |||
| controller.add(base64Encode(filteredBytes)); | |||
| } | |||
| } catch (e, stackTrace) { | |||
| controller.addError(e, stackTrace); | |||
| } finally { | |||
| controller.close(); // On ferme le stream quand tout est fini. | |||
| } | |||
| } | |||
| Future<http.Response> _createImageRequest({ | |||
| required String base64Image, | |||
| required String prompt, | |||
| required int width, | |||
| required int height, | |||
| required double denoisingStrength, | |||
| required double cfgScale, | |||
| }) { | |||
| final requestBody = { | |||
| 'init_images': [base64Image], | |||
| 'prompt': prompt, | |||
| 'seed': -1, | |||
| 'negative_prompt': 'blurry, low quality, artifacts, distorted, oversaturated, plastic skin, over-sharpen, artificial, harsh shadows, filters, watermark, cold lighting, blue tones, washed out, unnatural, sepia, text, letters', | |||
| 'steps': 25, // 25 est souvent suffisant pour img2img | |||
| 'cfg_scale': cfgScale, | |||
| 'denoising_strength': denoisingStrength, | |||
| 'sampler_name': 'DPM++ 2M Karras', | |||
| 'width': width, | |||
| 'height': height, | |||
| 'restore_faces': false, | |||
| }; | |||
| return http.post( | |||
| Uri.parse(_apiUrl), | |||
| headers: {'Content-Type': 'application/json'}, | |||
| body: jsonEncode(requestBody), | |||
| ).timeout(const Duration(minutes: 5)); | |||
| } | |||
| } | |||
| @@ -0,0 +1,60 @@ | |||
| import 'dart:convert'; | |||
| import 'package:http/http.dart' as http; | |||
| /// Définit un contrat pour tout service capable d'améliorer un texte. | |||
| abstract class TextImprovementService { | |||
| Future<String> improveText({ | |||
| required String originalText, | |||
| required String userInstruction, | |||
| }); | |||
| } | |||
| /// L'implémentation Ollama de ce service. | |||
| class OllamaTextImprovementService implements TextImprovementService { | |||
| final String _apiUrl = 'http://192.168.20.200:11434/api/generate'; | |||
| final String _textModel = 'gpt-oss:20b'; | |||
| @override | |||
| Future<String> improveText({ | |||
| required String originalText, | |||
| required String userInstruction, | |||
| }) async { | |||
| print("[OllamaTextImprovementService] 🚀 Appel pour améliorer le texte..."); | |||
| final requestPrompt = """ | |||
| You are a social media writing assistant. | |||
| A user wants to improve the following text: | |||
| --- TEXT TO IMPROVE --- | |||
| $originalText | |||
| ----------------------- | |||
| The user's instruction is: "$userInstruction". | |||
| Rewrite the text based on the instruction. | |||
| Your output MUST be ONLY the improved text, without any extra commentary or explanations. | |||
| """; | |||
| final requestBody = { | |||
| 'model': _textModel, | |||
| 'prompt': requestPrompt, | |||
| 'stream': false, | |||
| }; | |||
| try { | |||
| final response = await http.post( | |||
| Uri.parse(_apiUrl), | |||
| headers: {'Content-Type': 'application/json'}, | |||
| body: jsonEncode(requestBody), | |||
| ).timeout(const Duration(minutes: 2)); | |||
| if (response.statusCode == 200) { | |||
| final responseData = jsonDecode(response.body); | |||
| return (responseData['response'] as String? ?? '').trim(); | |||
| } else { | |||
| throw Exception('Erreur Ollama (improveText) ${response.statusCode}: ${response.body}'); | |||
| } | |||
| } catch (e) { | |||
| print("[OllamaTextImprovementService] ❌ Exception : ${e.toString()}"); | |||
| rethrow; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| # This file is automatically generated by Android Studio. | |||
| # Do not modify this file -- YOUR CHANGES WILL BE ERASED! | |||
| sdk.dir=/path/to/android/sdk | |||
| flutter.sdk=/path/to/flutter/sdk | |||
| flutter.buildMode=debug | |||
| flutter.versionName=1.0.0 | |||
| flutter.versionCode=1 | |||
| @@ -0,0 +1,69 @@ | |||
| name: social_content_creator | |||
| description: Application Flutter pour créer du contenu IA pour réseaux sociaux avec profils personnalisés. | |||
| publish_to: 'none' | |||
| version: 1.0.0+1 | |||
| environment: | |||
| sdk: '>=3.8.0 <4.0.0' | |||
| flutter: '>=3.35.0' | |||
| dependencies: | |||
| flutter: | |||
| sdk: flutter | |||
| image: ^4.0.0 | |||
| # UI & Design | |||
| cupertino_icons: ^1.0.8 | |||
| google_fonts: ^6.3.2 | |||
| # Image & Media Handling | |||
| image_picker: ^1.2.0 | |||
| cached_network_image: ^3.4.0 | |||
| # HTTP & API | |||
| http: ^1.3.0 | |||
| dio: ^5.6.0 | |||
| # State Management | |||
| provider: ^6.1.2 | |||
| riverpod: ^2.6.0 | |||
| # File Handling | |||
| path_provider: ^2.1.3 | |||
| archive: ^3.6.0 | |||
| # Social Sharing | |||
| share_plus: ^8.0.0 | |||
| # Storage | |||
| shared_preferences: ^2.3.0 | |||
| # Utilities | |||
| path: ^1.9.0 | |||
| intl: ^0.20.0 | |||
| uuid: ^4.4.0 | |||
| flutter_facebook_auth: ^7.1.0 | |||
| dev_dependencies: | |||
| flutter_test: | |||
| sdk: flutter | |||
| flutter_lints: ^4.0.0 | |||
| build_runner: ^2.4.0 | |||
| flutter: | |||
| uses-material-design: true | |||
| assets: | |||
| - assets/images/ | |||
| - assets/icons/ | |||
| fonts: | |||
| - family: Inter | |||
| fonts: | |||
| - asset: assets/fonts/Inter-Regular.ttf | |||
| - asset: assets/fonts/Inter-Bold.ttf | |||
| weight: 700 | |||
| - asset: assets/fonts/Inter-SemiBold.ttf | |||
| weight: 600 | |||
| @@ -0,0 +1,30 @@ | |||
| // This is a basic Flutter widget test. | |||
| // | |||
| // To perform an interaction with a widget in your test, use the WidgetTester | |||
| // utility in the flutter_test package. For example, you can send tap and scroll | |||
| // gestures. You can also use WidgetTester to find child widgets in the widget | |||
| // tree, read text, and verify that the values of widget properties are correct. | |||
| import 'package:flutter/material.dart'; | |||
| import 'package:flutter_test/flutter_test.dart'; | |||
| import 'package:social_content_creator/main.dart'; | |||
| void main() { | |||
| testWidgets('Counter increments smoke test', (WidgetTester tester) async { | |||
| // Build our app and trigger a frame. | |||
| await tester.pumpWidget(const MyApp()); | |||
| // Verify that our counter starts at 0. | |||
| expect(find.text('0'), findsOneWidget); | |||
| expect(find.text('1'), findsNothing); | |||
| // Tap the '+' icon and trigger a frame. | |||
| await tester.tap(find.byIcon(Icons.add)); | |||
| await tester.pump(); | |||
| // Verify that our counter has incremented. | |||
| expect(find.text('0'), findsNothing); | |||
| expect(find.text('1'), findsOneWidget); | |||
| }); | |||
| } | |||