Initial upload: fin65-mobile

This commit is contained in:
Abderrahim-Mouhcine
2026-08-16 19:59:55 +01:00
commit 46558a5ec8
242 changed files with 16334 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
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-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# 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
+45
View File
@@ -0,0 +1,45 @@
# 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: "924134a44c189315be2148659913dda1671cbe99"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 924134a44c189315be2148659913dda1671cbe99
base_revision: 924134a44c189315be2148659913dda1671cbe99
- platform: android
create_revision: 924134a44c189315be2148659913dda1671cbe99
base_revision: 924134a44c189315be2148659913dda1671cbe99
- platform: ios
create_revision: 924134a44c189315be2148659913dda1671cbe99
base_revision: 924134a44c189315be2148659913dda1671cbe99
- platform: linux
create_revision: 924134a44c189315be2148659913dda1671cbe99
base_revision: 924134a44c189315be2148659913dda1671cbe99
- platform: macos
create_revision: 924134a44c189315be2148659913dda1671cbe99
base_revision: 924134a44c189315be2148659913dda1671cbe99
- platform: web
create_revision: 924134a44c189315be2148659913dda1671cbe99
base_revision: 924134a44c189315be2148659913dda1671cbe99
- platform: windows
create_revision: 924134a44c189315be2148659913dda1671cbe99
base_revision: 924134a44c189315be2148659913dda1671cbe99
# 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'
+17
View File
@@ -0,0 +1,17 @@
# fin65_mobile
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
+43
View File
@@ -0,0 +1,43 @@
# FIN65 - Patch icône Android
Ce patch remplace l’icône de l’application mobile Flutter FIN65.
## Fichiers inclus
```text
assets/icon/app_icon.png
assets/icon/adaptive_foreground.png
flutter_launcher_icons.yaml
apply_fin65_android_icon.bat
remove_icon_config_only.bat
```
## Installation
1. Décompresse ce dossier.
2. Lance :
```powershell
.\apply_fin65_android_icon.bat
```
Le script va :
- copier les images dans `D:\07-Logiciels\20-FIN65\fin65_mobile\assets\icon`
- ajouter `flutter_launcher_icons` en dépendance de développement
- générer les icônes Android natives
- lancer `flutter clean` et `flutter pub get`
## Après application
Relance l’application :
```powershell
cd D:\07-Logiciels\20-FIN65\fin65_mobile
flutter run
```
Si l'ancienne icône reste visible sur le téléphone :
- désinstalle l'app FIN65 du téléphone ;
- relance `flutter run`.
Android garde parfois l’icône en cache, parce que même les pixels ont maintenant de l’inertie administrative.
+45
View File
@@ -0,0 +1,45 @@
# FIN65 Mobile - Dashboard couleurs V2.7.1
Ce patch ajoute des couleurs aux rubriques du dashboard mobile.
## Couleurs ajoutées
- Disponible réel : vert
- Balance prévisionnelle : bleu
- Solde total : violet
- Balance réelle : vert si positif, rouge si négatif
- Revenus réels : vert
- Dépenses réelles : rouge
- Dépenses échéancier : orange
- Transferts prévus : bleu
- Provisions prévues : violet
## Installation
Décompresse le ZIP, puis lance :
```powershell
.\apply_mobile_dashboard_colors_v2_7_1.bat
```
Ensuite :
```powershell
cd D:\07-Logiciels\20-FIN65\fin65_mobile
flutter run
```
## Rollback
Le script sauvegarde les anciens fichiers ici :
```text
D:\07-Logiciels\20-FIN65\fin65_mobile\backup_before_mobile_dashboard_colors_v271
```
Fichiers sauvegardés :
```text
dashboard.dart.bak
home_screen.dart.bak
```
@@ -0,0 +1,36 @@
# FIN65 Mobile - Dashboard couleurs V2.7.2 FIX
Corrige l'erreur :
```text
'_MetricCard' is already declared in this scope
```
## Cause
Le patch V2.7.1 a laissé deux déclarations de `_MetricCard` dans `home_screen.dart`. Oui, Flutter n'aime pas qu'on lui présente deux jumeaux avec le même passeport.
## Installation
Décompresse ce ZIP, puis lance :
```powershell
.\apply_mobile_dashboard_colors_v2_7_2_fix.bat
```
Puis :
```powershell
cd D:\07-Logiciels\20-FIN65\fin65_mobile
flutter run
```
## Contrôle
Le script affiche les lignes contenant :
```text
class _MetricCard
```
Il doit y en avoir une seule.
@@ -0,0 +1,63 @@
# FIN65 Mobile - Dashboard avec infos Échéancier V2.7
Ce patch ajoute au tableau de bord mobile les infos renvoyées par le backend après le patch Échéancier.
## Ajouts visibles sur mobile
Dans l’accueil mobile :
- `Balance prévisionnelle du mois`
- bloc `Prévisionnel du mois`
- dépenses prévues restantes
- revenus prévus échéancier
- dépenses échéancier
- transferts prévus
- provisions prévues
## Ce que ce patch ne fait pas encore
Il n’ajoute pas encore une vraie page mobile pour créer/modifier l’Échéancier.
Il affiche les infos du backend sur l’accueil mobile. On évite de recréer tout un mini-Excel dans un écran de téléphone, par respect pour les pouces humains.
## Installation
Décompresse ce ZIP, puis lance :
```powershell
.\apply_mobile_dashboard_scheduled_infos_v2_7.bat
```
Ensuite :
```powershell
cd D:\07-Logiciels\20-FIN65\fin65_mobile
flutter run
```
## Test
1. Vérifie que l’URL API mobile pointe vers le serveur :
```text
http://192.168.1.53:8000
```
2. Dans l’app mobile :
- Synchronisation
- Synchroniser maintenant
3. Reviens à l’accueil : les cartes de prévisionnel doivent apparaître.
## Important
Le backend doit déjà renvoyer les nouveaux champs `/dashboard` :
```text
scheduled_expenses_remaining_total
scheduled_income_remaining_total
scheduled_transfer_remaining_total
scheduled_provision_remaining_total
projected_month_balance
```
Si le mobile affiche 0, vérifie d’abord que l’échéancier existe bien sur le web et que l’opération est dans le mois courant.
+145
View File
@@ -0,0 +1,145 @@
# FIN65 Mobile V2 - Offline-first SQLite
Cette version ajoute une vraie base locale SQLite côté mobile.
## Ce que ça change
- L'app affiche les dernières données connues même si le backend PC est éteint.
- L'app garde localement :
- les comptes ;
- les catégories ;
- le dashboard ;
- les dernières transactions ;
- les opérations en attente.
- Si le backend est indisponible, une nouvelle dépense/recette/transfert est placée dans une file d'attente locale.
- Quand le backend revient, le bouton "Synchroniser" envoie les opérations en attente, puis recharge les données fraîches.
## Installation dans le projet Flutter existant
Depuis :
```powershell
D:\07-Logiciels\20-FIN65\fin65_mobile
```
Copie/remplace depuis ce ZIP :
```text
pubspec.yaml
lib\
README_MOBILE_OFFLINE_SQLITE.md
```
Puis lance :
```powershell
cd D:\07-Logiciels\20-FIN65\fin65_mobile
$env:PUB_CACHE = "D:\07-Logiciels\20-FIN65\.pub-cache"
flutter clean
Remove-Item -Recurse -Force .\.dart_tool -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force .\build -ErrorAction SilentlyContinue
Remove-Item -Force .\pubspec.lock -ErrorAction SilentlyContinue
flutter pub get
flutter run
```
## Backend
Le backend reste la source officielle des données.
```powershell
cd D:\07-Logiciels\20-FIN65\finance_app_backend
py -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload
```
Dans l'app mobile, configurer l'URL :
```text
http://IP_DU_PC:8000
```
## Règles de synchronisation
- Les créations mobiles sont synchronisées plus tard.
- Les modifications/suppressions hors ligne ne sont pas encore activées pour éviter les conflits.
- Les soldes affichés hors ligne sont des soldes estimés quand il existe des opérations en attente.
## V2.1 - Correctif locale FR
Correction de l'erreur Flutter :
```text
LocaleDataException: Locale data has not been initialized
```
Le fichier `lib/main.dart` initialise maintenant les données de formatage :
```dart
WidgetsFlutterBinding.ensureInitialized();
await initializeDateFormatting('fr_FR', null);
```
## V2.2 - Correctif sauvegarde hors ligne
Correction du problème observé lors de l'enregistrement d'une dépense avec le backend éteint.
Changements :
- le formulaire attend maintenant le rechargement SQLite local avant de fermer l'écran ;
- les erreurs de rechargement/synchronisation sont affichées au lieu de produire un écran noir ;
- `ErrorWidget` affiche une erreur lisible si un widget Flutter échoue ;
- validation plus stricte des comptes source/cible.
## V2.3 - Boutons rapides
Ajout de raccourcis dans l'écran d'ajout rapide :
- dépenses rapides : Courses, Essence, Restaurant, Café, Transport, Santé, Abonnement, Retrait cash ;
- recettes rapides : Salaire, Remboursement, Prime, Vente ;
- transferts rapides : Épargne, Provision, Banque → Cash, Cash → Banque ;
- montants rapides : 10, 20, 50, 100, 200, 500, 1000.
Les boutons pré-remplissent le libellé et essaient d'associer automatiquement une catégorie existante quand le nom est proche.
## V2.4.1 - Correctif build bouton fixe
Correction de compilation : le bouton `Enregistrer` fixe est maintenant déclaré dans l'écran principal d'ajout rapide, pas dans le composant interne des boutons rapides.
## V2.5 - Page Synchronisation + verrouillage PIN
Ajouts :
- vraie page `Synchronisation` avec statut backend, URL API, dernière synchro, file d'attente locale et bouton de synchronisation ;
- accès à la page depuis l’icône nuage dans la barre du haut ;
- badge avec le nombre d’opérations en attente ;
- écran `Sécurité` dans les paramètres ;
- activation/changement/désactivation du PIN ;
- verrouillage automatique de l’app quand elle passe en arrière-plan ;
- demande du PIN au prochain retour dans l’app.
Note :
- Le PIN protège l’accès local à l’app.
- Si le backend est exposé sur Internet, il faudra aussi ajouter une vraie authentification serveur.
## V2.6 - Déverrouillage par empreinte
Ajouts :
- dépendance Flutter `local_auth` ;
- activation/désactivation de l'empreinte dans `Paramètres > Sécurité` ;
- déverrouillage par empreinte sur l'écran de verrouillage ;
- le PIN reste toujours disponible en secours ;
- patch Android inclus dans `android_biometrics_patch`.
Installation :
1. Remplacer `lib`, `pubspec.yaml` et `README_MOBILE_OFFLINE_SQLITE.md`.
2. Lancer `flutter pub get`.
3. Lancer le patch Android :
`android_biometrics_patch\apply_android_biometrics_patch.bat`
4. Relancer `flutter run`.
Important :
- Active d’abord le PIN dans l’app.
- Ensuite seulement active l’empreinte.
- L’empreinte dépend des empreintes déjà configurées dans Android.
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -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
+43
View File
@@ -0,0 +1,43 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.fin65_mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.fin65_mobile"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 24
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,45 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.fin65_mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.fin65_mobile"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,46 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.fin65_mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.fin65_mobile"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 24
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,42 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.fin65_mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.fin65_mobile"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 24
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,41 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.fin65_mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.fin65_mobile"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 24
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -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>
+14
View File
@@ -0,0 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.INTERNET" />
<application android:label="FIN65" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:usesCleartextTraffic="true">
<activity android:name="com.example.fin65_mobile.MainActivity" android:exported="true" android:launchMode="singleTop" android:taskAffinity="" 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" />
</application>
</manifest>
@@ -0,0 +1,32 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="FIN65"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
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" />
</application>
</manifest>
@@ -0,0 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.INTERNET" />
<application android:label="FIN65" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:usesCleartextTraffic="true">
<activity android:name="com.example.fin65_mobile.MainActivity" android:exported="true" android:launchMode="singleTop" android:taskAffinity="" 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" />
</application>
</manifest>
@@ -0,0 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.INTERNET" />
<application android:label="FIN65" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:usesCleartextTraffic="true">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:taskAffinity="" 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" />
</application>
</manifest>
@@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:roundIcon=@mipmap/ic_launcher_round
android:label="FIN65"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,6 @@
package com.example.fin65_mobile;
import io.flutter.embedding.android.FlutterFragmentActivity;
public class MainActivity extends FlutterFragmentActivity {
}
@@ -0,0 +1,6 @@
package com.example.fin65_mobile;
import io.flutter.embedding.android.FlutterFragmentActivity;
public class MainActivity extends FlutterFragmentActivity {
}
@@ -0,0 +1,5 @@
package com.example.fin65_mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,6 @@
package com.example.fin65_mobile
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity()
@@ -0,0 +1,5 @@
package com.example.fin65_mobile
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity()
@@ -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,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

@@ -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,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0F172A</color>
</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,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>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")
@@ -0,0 +1,29 @@
# FIN65 - Patch Android biométrie
Ce patch prépare Android pour `local_auth`.
Il fait :
- ajoute `android.permission.USE_BIOMETRIC` dans `AndroidManifest.xml` ;
- garde `android.permission.INTERNET` ;
- garde `android:usesCleartextTraffic="true"` ;
- force le label `FIN65` ;
- modifie `MainActivity` pour hériter de `FlutterFragmentActivity` ;
- tente de mettre `minSdk` à 24 si nécessaire ;
- lance `flutter clean` et `flutter pub get`.
## Utilisation
Depuis ce dossier :
```powershell
.\apply_android_biometrics_patch.bat
```
Puis :
```powershell
cd D:\07-Logiciels\20-FIN65\fin65_mobile
flutter run
```
Des sauvegardes `.before_biometric_*.bak` sont créées avant modification.
@@ -0,0 +1,4 @@
@echo off
setlocal
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0apply_android_biometrics_patch.ps1"
@@ -0,0 +1,128 @@
$MobileDir = "D:\07-Logiciels\20-FIN65\fin65_mobile"
$Manifest = Join-Path $MobileDir "android\app\src\main\AndroidManifest.xml"
$AndroidDir = Join-Path $MobileDir "android\app\src\main"
$KotlinRoot = Join-Path $AndroidDir "kotlin"
$JavaRoot = Join-Path $AndroidDir "java"
Write-Host ""
Write-Host "=========================================="
Write-Host " FIN65 - Patch Android biometrie"
Write-Host "=========================================="
Write-Host ""
if (!(Test-Path $MobileDir)) {
Write-Host "[ERREUR] Dossier mobile introuvable : $MobileDir" -ForegroundColor Red
Read-Host "Appuie sur Entree pour fermer"
exit 1
}
if (!(Test-Path $Manifest)) {
Write-Host "[ERREUR] AndroidManifest.xml introuvable : $Manifest" -ForegroundColor Red
Read-Host "Appuie sur Entree pour fermer"
exit 1
}
$Stamp = Get-Date -Format "yyyyMMdd_HHmmss"
Copy-Item $Manifest "$Manifest.before_biometric_$Stamp.bak" -Force
[xml]$xml = Get-Content $Manifest
$manifestNode = $xml.manifest
$androidNs = "http://schemas.android.com/apk/res/android"
function Ensure-Permission($name) {
$existing = $manifestNode.'uses-permission' | Where-Object {
$_.GetAttribute("name", $androidNs) -eq $name
}
if ($null -eq $existing) {
$perm = $xml.CreateElement("uses-permission")
$attr = $xml.CreateAttribute("android", "name", $androidNs)
$attr.Value = $name
[void]$perm.Attributes.Append($attr)
[void]$manifestNode.InsertBefore($perm, $manifestNode.FirstChild)
Write-Host "Permission ajoutee : $name"
} else {
Write-Host "Permission deja presente : $name"
}
}
Ensure-Permission "android.permission.INTERNET"
Ensure-Permission "android.permission.USE_BIOMETRIC"
$app = $manifestNode.application
if ($app -ne $null) {
$app.SetAttribute("label", $androidNs, "FIN65")
$app.SetAttribute("icon", $androidNs, "@mipmap/ic_launcher")
$app.SetAttribute("roundIcon", $androidNs, "@mipmap/ic_launcher_round")
$app.SetAttribute("usesCleartextTraffic", $androidNs, "true")
}
$xml.Save($Manifest)
Write-Host "AndroidManifest.xml mis a jour."
# MainActivity: local_auth Android demande FlutterFragmentActivity.
$mainFiles = @()
if (Test-Path $KotlinRoot) {
$mainFiles += Get-ChildItem $KotlinRoot -Recurse -Filter MainActivity.kt -ErrorAction SilentlyContinue
}
if (Test-Path $JavaRoot) {
$mainFiles += Get-ChildItem $JavaRoot -Recurse -Filter MainActivity.java -ErrorAction SilentlyContinue
}
if ($mainFiles.Count -eq 0) {
Write-Host "[ATTENTION] MainActivity introuvable. Aucun patch FragmentActivity applique." -ForegroundColor Yellow
} else {
foreach ($file in $mainFiles) {
Copy-Item $file.FullName "$($file.FullName).before_biometric_$Stamp.bak" -Force
$text = Get-Content $file.FullName -Raw
if ($file.Extension -eq ".kt") {
$text = $text -replace "import io\.flutter\.embedding\.android\.FlutterActivity", "import io.flutter.embedding.android.FlutterFragmentActivity"
if ($text -notmatch "FlutterFragmentActivity") {
$text = $text -replace "package ([^\r\n]+)", "package `$1`n`nimport io.flutter.embedding.android.FlutterFragmentActivity"
}
$text = $text -replace ":\s*FlutterActivity\(\)", ": FlutterFragmentActivity()"
$text = $text -replace "extends\s+FlutterActivity", "extends FlutterFragmentActivity"
Set-Content $file.FullName $text -Encoding UTF8
Write-Host "MainActivity.kt mis a jour : $($file.FullName)"
}
if ($file.Extension -eq ".java") {
$text = $text -replace "import io\.flutter\.embedding\.android\.FlutterActivity;", "import io.flutter.embedding.android.FlutterFragmentActivity;"
if ($text -notmatch "FlutterFragmentActivity") {
$text = $text -replace "(package [^;]+;)", "`$1`n`nimport io.flutter.embedding.android.FlutterFragmentActivity;"
}
$text = $text -replace "extends\s+FlutterActivity", "extends FlutterFragmentActivity"
Set-Content $file.FullName $text -Encoding UTF8
Write-Host "MainActivity.java mis a jour : $($file.FullName)"
}
}
}
# Tente de relever minSdk a 24 si un fichier Gradle classique existe.
$gradleFiles = @(
(Join-Path $MobileDir "android\app\build.gradle"),
(Join-Path $MobileDir "android\app\build.gradle.kts")
)
foreach ($g in $gradleFiles) {
if (Test-Path $g) {
Copy-Item $g "$g.before_biometric_$Stamp.bak" -Force
$text = Get-Content $g -Raw
$text = $text -replace "minSdkVersion\s+flutter\.minSdkVersion", "minSdkVersion 24"
$text = $text -replace "minSdk\s*=\s*flutter\.minSdkVersion", "minSdk = 24"
$text = $text -replace "minSdkVersion\s+\d+", "minSdkVersion 24"
$text = $text -replace "minSdk\s*=\s*\d+", "minSdk = 24"
Set-Content $g $text -Encoding UTF8
Write-Host "Gradle minSdk verifie : $g"
}
}
Set-Location $MobileDir
flutter clean
flutter pub get
Write-Host ""
Write-Host "Patch Android biometrie termine."
Write-Host "Relance maintenant : flutter run"
Write-Host ""
Read-Host "Appuie sur Entree pour fermer"
+79
View File
@@ -0,0 +1,79 @@
@echo off
setlocal
REM ============================================================
REM FIN65 - Remplacer l'icone Android Flutter
REM ============================================================
set "ROOT_DIR=D:\07-Logiciels\20-FIN65"
set "MOBILE_DIR=%ROOT_DIR%\fin65_mobile"
set "PUB_CACHE=%ROOT_DIR%\.pub-cache"
echo.
echo ==========================================
echo FIN65 - Remplacement icone Android
echo ==========================================
echo.
if not exist "%MOBILE_DIR%" (
echo [ERREUR] Dossier mobile introuvable :
echo %MOBILE_DIR%
echo.
pause
exit /b 1
)
cd /d "%MOBILE_DIR%"
if not exist "pubspec.yaml" (
echo [ERREUR] pubspec.yaml introuvable dans :
echo %MOBILE_DIR%
echo.
pause
exit /b 1
)
mkdir "assets\icon" >nul 2>&1
copy /Y "%~dp0assets\icon\app_icon.png" "assets\icon\app_icon.png" >nul
copy /Y "%~dp0assets\icon\adaptive_foreground.png" "assets\icon\adaptive_foreground.png" >nul
copy /Y "%~dp0flutter_launcher_icons.yaml" "flutter_launcher_icons.yaml" >nul
echo Icones copiees dans :
echo %MOBILE_DIR%\assets\icon
echo.
echo Ajout/verif du package flutter_launcher_icons...
flutter pub add --dev flutter_launcher_icons
if errorlevel 1 (
echo.
echo [ERREUR] Impossible d'ajouter flutter_launcher_icons.
echo Verifie la connexion internet et Flutter.
pause
exit /b 1
)
echo.
echo Generation des icones Android...
dart run flutter_launcher_icons
if errorlevel 1 (
echo.
echo [ERREUR] Generation des icones echouee.
pause
exit /b 1
)
echo.
echo Nettoyage Flutter...
flutter clean
flutter pub get
echo.
echo ==========================================
echo Icone FIN65 appliquee.
echo ==========================================
echo.
echo Relance ensuite :
echo flutter run
echo.
pause
+54
View File
@@ -0,0 +1,54 @@
@echo off
setlocal
REM ============================================================
REM FIN65 Mobile - Dashboard couleurs V2.7.1
REM ============================================================
set "ROOT_DIR=D:\07-Logiciels\20-FIN65"
set "MOBILE_DIR=%ROOT_DIR%\fin65_mobile"
set "PUB_CACHE=%ROOT_DIR%\.pub-cache"
echo.
echo ==========================================
echo FIN65 Mobile - Dashboard couleurs V2.7.1
echo ==========================================
echo.
if not exist "%MOBILE_DIR%" (
echo [ERREUR] Dossier mobile introuvable :
echo %MOBILE_DIR%
pause
exit /b 1
)
set "BACKUP_DIR=%MOBILE_DIR%\backup_before_mobile_dashboard_colors_v271"
mkdir "%BACKUP_DIR%" >nul 2>&1
if exist "%MOBILE_DIR%\lib\models\dashboard.dart" (
copy /Y "%MOBILE_DIR%\lib\models\dashboard.dart" "%BACKUP_DIR%\dashboard.dart.bak" >nul
)
if exist "%MOBILE_DIR%\lib\screens\home_screen.dart" (
copy /Y "%MOBILE_DIR%\lib\screens\home_screen.dart" "%BACKUP_DIR%\home_screen.dart.bak" >nul
)
copy /Y "%~dp0lib\models\dashboard.dart" "%MOBILE_DIR%\lib\models\dashboard.dart" >nul
copy /Y "%~dp0lib\screens\home_screen.dart" "%MOBILE_DIR%\lib\screens\home_screen.dart" >nul
cd /d "%MOBILE_DIR%"
echo.
echo Nettoyage Flutter...
flutter clean
flutter pub get
echo.
echo ==========================================
echo Patch couleurs applique.
echo ==========================================
echo.
echo Relance maintenant :
echo flutter run
echo.
pause
@@ -0,0 +1,58 @@
@echo off
setlocal
REM ============================================================
REM FIN65 Mobile - Dashboard couleurs V2.7.2 fix duplicate class
REM ============================================================
set "ROOT_DIR=D:\07-Logiciels\20-FIN65"
set "MOBILE_DIR=%ROOT_DIR%\fin65_mobile"
set "PUB_CACHE=%ROOT_DIR%\.pub-cache"
echo.
echo ==========================================
echo FIN65 Mobile - Couleurs V2.7.2 FIX
echo ==========================================
echo.
if not exist "%MOBILE_DIR%" (
echo [ERREUR] Dossier mobile introuvable :
echo %MOBILE_DIR%
pause
exit /b 1
)
set "BACKUP_DIR=%MOBILE_DIR%\backup_before_mobile_dashboard_colors_v272"
mkdir "%BACKUP_DIR%" >nul 2>&1
if exist "%MOBILE_DIR%\lib\models\dashboard.dart" (
copy /Y "%MOBILE_DIR%\lib\models\dashboard.dart" "%BACKUP_DIR%\dashboard.dart.bak" >nul
)
if exist "%MOBILE_DIR%\lib\screens\home_screen.dart" (
copy /Y "%MOBILE_DIR%\lib\screens\home_screen.dart" "%BACKUP_DIR%\home_screen.dart.bak" >nul
)
copy /Y "%~dp0lib\models\dashboard.dart" "%MOBILE_DIR%\lib\models\dashboard.dart" >nul
copy /Y "%~dp0lib\screens\home_screen.dart" "%MOBILE_DIR%\lib\screens\home_screen.dart" >nul
cd /d "%MOBILE_DIR%"
echo.
echo Nettoyage Flutter...
flutter clean
flutter pub get
echo.
echo Verification rapide...
findstr /N /C:"class _MetricCard" lib\screens\home_screen.dart
echo.
echo ==========================================
echo Fix applique. Il doit y avoir UNE seule ligne _MetricCard.
echo ==========================================
echo.
echo Relance maintenant :
echo flutter run
echo.
pause
@@ -0,0 +1,66 @@
@echo off
setlocal
REM ============================================================
REM FIN65 Mobile - Dashboard avec infos echeancier V2.7
REM ============================================================
set "ROOT_DIR=D:\07-Logiciels\20-FIN65"
set "MOBILE_DIR=%ROOT_DIR%\fin65_mobile"
set "PUB_CACHE=%ROOT_DIR%\.pub-cache"
echo.
echo ==========================================
echo FIN65 Mobile - Infos echeancier V2.7
echo ==========================================
echo.
if not exist "%MOBILE_DIR%" (
echo [ERREUR] Dossier mobile introuvable :
echo %MOBILE_DIR%
pause
exit /b 1
)
if not exist "%MOBILE_DIR%\lib\models" (
echo [ERREUR] Dossier models introuvable.
pause
exit /b 1
)
if not exist "%MOBILE_DIR%\lib\screens" (
echo [ERREUR] Dossier screens introuvable.
pause
exit /b 1
)
set "BACKUP_DIR=%MOBILE_DIR%\backup_before_mobile_dashboard_scheduled_v27"
mkdir "%BACKUP_DIR%" >nul 2>&1
if exist "%MOBILE_DIR%\lib\models\dashboard.dart" (
copy /Y "%MOBILE_DIR%\lib\models\dashboard.dart" "%BACKUP_DIR%\dashboard.dart.bak" >nul
)
if exist "%MOBILE_DIR%\lib\screens\home_screen.dart" (
copy /Y "%MOBILE_DIR%\lib\screens\home_screen.dart" "%BACKUP_DIR%\home_screen.dart.bak" >nul
)
copy /Y "%~dp0lib\models\dashboard.dart" "%MOBILE_DIR%\lib\models\dashboard.dart" >nul
copy /Y "%~dp0lib\screens\home_screen.dart" "%MOBILE_DIR%\lib\screens\home_screen.dart" >nul
cd /d "%MOBILE_DIR%"
echo.
echo Nettoyage Flutter...
flutter clean
flutter pub get
echo.
echo ==========================================
echo Patch mobile applique.
echo ==========================================
echo.
echo Relance maintenant :
echo flutter run
echo.
pause
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -0,0 +1,251 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../services/security_service.dart';
class AppLockGate extends StatefulWidget {
const AppLockGate({
super.key,
required this.securityService,
required this.child,
});
final SecurityService securityService;
final Widget child;
@override
State<AppLockGate> createState() => _AppLockGateState();
}
class _AppLockGateState extends State<AppLockGate> with WidgetsBindingObserver {
bool _checking = true;
bool _locked = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadLockState();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) {
_lockIfNeeded();
}
}
Future<void> _loadLockState() async {
final enabled = await widget.securityService.isPinEnabled();
if (!mounted) return;
setState(() {
_locked = enabled;
_checking = false;
});
}
Future<void> _lockIfNeeded() async {
final enabled = await widget.securityService.isPinEnabled();
if (!mounted || !enabled) return;
setState(() => _locked = true);
}
void _unlock() {
setState(() => _locked = false);
}
@override
Widget build(BuildContext context) {
if (_checking) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (_locked) {
return PinUnlockScreen(
securityService: widget.securityService,
onUnlocked: _unlock,
);
}
return widget.child;
}
}
class PinUnlockScreen extends StatefulWidget {
const PinUnlockScreen({
super.key,
required this.securityService,
required this.onUnlocked,
});
final SecurityService securityService;
final VoidCallback onUnlocked;
@override
State<PinUnlockScreen> createState() => _PinUnlockScreenState();
}
class _PinUnlockScreenState extends State<PinUnlockScreen> {
final _pinController = TextEditingController();
bool _checking = false;
bool _checkingBiometric = false;
bool _biometricEnabled = false;
bool _biometricAvailable = false;
bool _biometricAttempted = false;
String? _error;
@override
void initState() {
super.initState();
_loadBiometricState();
}
@override
void dispose() {
_pinController.dispose();
super.dispose();
}
Future<void> _loadBiometricState() async {
final enabled = await widget.securityService.isBiometricEnabled();
final available = await widget.securityService.isBiometricAvailable();
if (!mounted) return;
setState(() {
_biometricEnabled = enabled;
_biometricAvailable = available;
});
if (enabled && available && !_biometricAttempted) {
_biometricAttempted = true;
await Future.delayed(const Duration(milliseconds: 250));
if (mounted) await _unlockWithBiometrics();
}
}
Future<void> _unlockWithBiometrics() async {
setState(() {
_checkingBiometric = true;
_error = null;
});
final ok = await widget.securityService.authenticateWithBiometrics();
if (!mounted) return;
if (ok) {
widget.onUnlocked();
} else {
setState(() {
_checkingBiometric = false;
_error = 'Empreinte refusée ou annulée. Utilise le PIN.';
});
}
}
Future<void> _unlock() async {
setState(() {
_checking = true;
_error = null;
});
final ok = await widget.securityService.verifyPin(_pinController.text.trim());
if (!mounted) return;
if (ok) {
_pinController.clear();
widget.onUnlocked();
} else {
HapticFeedback.vibrate();
setState(() {
_checking = false;
_error = 'PIN incorrect.';
});
}
}
@override
Widget build(BuildContext context) {
final showBiometric = _biometricEnabled && _biometricAvailable;
return Scaffold(
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.all(24),
children: [
Icon(showBiometric ? Icons.fingerprint : Icons.lock, size: 64),
const SizedBox(height: 16),
Text(
'FIN65 verrouillé',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
showBiometric
? 'Déverrouille avec ton empreinte ou ton PIN.'
: 'Entre ton PIN pour accéder aux données locales.',
textAlign: TextAlign.center,
),
if (showBiometric) ...[
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _checkingBiometric ? null : _unlockWithBiometrics,
icon: _checkingBiometric
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.fingerprint),
label: Text(_checkingBiometric ? 'Vérification...' : 'Utiliser l’empreinte'),
),
],
const SizedBox(height: 24),
TextField(
controller: _pinController,
autofocus: !showBiometric,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: InputDecoration(
labelText: 'PIN',
border: const OutlineInputBorder(),
errorText: _error,
counterText: '',
),
onSubmitted: (_) => _checking ? null : _unlock(),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _checking ? null : _unlock,
icon: _checking
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.lock_open),
label: Text(_checking ? 'Vérification...' : 'Déverrouiller avec le PIN'),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,399 @@
import 'package:flutter/material.dart';
import 'package:local_auth/local_auth.dart';
import '../services/security_service.dart';
class SecurityScreen extends StatefulWidget {
const SecurityScreen({
super.key,
required this.securityService,
});
final SecurityService securityService;
@override
State<SecurityScreen> createState() => _SecurityScreenState();
}
class _SecurityScreenState extends State<SecurityScreen> {
final _currentPinController = TextEditingController();
final _newPinController = TextEditingController();
final _confirmPinController = TextEditingController();
bool _loading = true;
bool _saving = false;
bool _enabled = false;
bool _biometricAvailable = false;
bool _biometricEnabled = false;
List<BiometricType> _availableBiometrics = const [];
String? _message;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_currentPinController.dispose();
_newPinController.dispose();
_confirmPinController.dispose();
super.dispose();
}
Future<void> _load() async {
final enabled = await widget.securityService.isPinEnabled();
final biometricAvailable = await widget.securityService.isBiometricAvailable();
final biometricEnabled = await widget.securityService.isBiometricEnabled();
final availableBiometrics = await widget.securityService.getAvailableBiometrics();
if (!mounted) return;
setState(() {
_enabled = enabled;
_biometricAvailable = biometricAvailable;
_biometricEnabled = biometricEnabled;
_availableBiometrics = availableBiometrics;
_loading = false;
});
}
Future<bool> _verifyCurrentPinFromFieldIfNeeded() async {
if (!_enabled) return true;
final current = _currentPinController.text.trim();
final ok = await widget.securityService.verifyPin(current);
if (!ok) {
setState(() => _message = 'PIN actuel incorrect.');
return false;
}
return true;
}
Future<String?> _askCurrentPinDialog({
required String title,
required String actionLabel,
}) async {
final controller = TextEditingController();
final result = await showDialog<String>(
context: context,
barrierDismissible: true,
builder: (context) {
return AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: const InputDecoration(
labelText: 'PIN actuel',
border: OutlineInputBorder(),
counterText: '',
),
onSubmitted: (_) => Navigator.of(context).pop(controller.text.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
child: Text(actionLabel),
),
],
);
},
);
controller.dispose();
return result?.trim();
}
Future<bool> _askAndVerifyCurrentPin({
required String title,
required String actionLabel,
}) async {
final pin = await _askCurrentPinDialog(
title: title,
actionLabel: actionLabel,
);
if (pin == null) {
setState(() => _message = 'Action annulée.');
return false;
}
final ok = await widget.securityService.verifyPin(pin);
if (!ok) {
setState(() => _message = 'PIN actuel incorrect.');
return false;
}
return true;
}
Future<void> _savePin() async {
final newPin = _newPinController.text.trim();
final confirm = _confirmPinController.text.trim();
if (newPin != confirm) {
setState(() => _message = 'Le nouveau PIN et la confirmation ne correspondent pas.');
return;
}
setState(() {
_saving = true;
_message = null;
});
try {
final currentOk = await _verifyCurrentPinFromFieldIfNeeded();
if (!currentOk) return;
await widget.securityService.setPin(newPin);
_currentPinController.clear();
_newPinController.clear();
_confirmPinController.clear();
await _load();
setState(() {
_message = 'Verrouillage PIN activé.';
});
} catch (e) {
setState(() => _message = 'Erreur : $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _disablePin() async {
setState(() {
_saving = true;
_message = null;
});
try {
final currentOk = await _askAndVerifyCurrentPin(
title: 'Désactiver le verrouillage PIN',
actionLabel: 'Désactiver',
);
if (!currentOk) return;
await widget.securityService.disablePin();
_currentPinController.clear();
_newPinController.clear();
_confirmPinController.clear();
await _load();
setState(() {
_message = 'Verrouillage PIN et biométrie désactivés.';
});
} catch (e) {
setState(() => _message = 'Erreur : $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _toggleBiometrics(bool value) async {
if (_saving) return;
setState(() {
_saving = true;
_message = null;
});
try {
if (value) {
if (!_enabled) {
setState(() => _message = 'Active d’abord un PIN avant la biométrie.');
return;
}
if (!_biometricAvailable) {
setState(() => _message = 'Aucune empreinte/biométrie disponible sur ce téléphone.');
return;
}
final currentOk = await _askAndVerifyCurrentPin(
title: 'Activer l’empreinte',
actionLabel: 'Continuer',
);
if (!currentOk) return;
await widget.securityService.setBiometricEnabled(true);
final confirmed = await widget.securityService.authenticateWithBiometrics();
if (!confirmed) {
await widget.securityService.setBiometricEnabled(false);
setState(() => _message = 'Empreinte annulée ou refusée. Biométrie non activée.');
return;
}
} else {
final currentOk = await _askAndVerifyCurrentPin(
title: 'Désactiver l’empreinte',
actionLabel: 'Désactiver',
);
if (!currentOk) return;
await widget.securityService.setBiometricEnabled(false);
}
await _load();
setState(() {
_message = value
? 'Déverrouillage par empreinte activé.'
: 'Déverrouillage par empreinte désactivé.';
});
} catch (e) {
setState(() => _message = 'Erreur : $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
String _biometricLabel() {
if (_availableBiometrics.isEmpty) return 'Aucune biométrie détectée.';
final labels = _availableBiometrics.map((type) {
return switch (type) {
BiometricType.fingerprint => 'empreinte',
BiometricType.face => 'visage',
BiometricType.iris => 'iris',
BiometricType.strong => 'biométrie forte',
BiometricType.weak => 'biométrie faible',
};
}).toSet().join(', ');
return 'Disponible : $labels.';
}
@override
Widget build(BuildContext context) {
if (_loading) {
return Scaffold(
appBar: AppBar(title: const Text('Sécurité')),
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(title: const Text('Sécurité')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: SwitchListTile(
value: _enabled,
onChanged: null,
secondary: Icon(_enabled ? Icons.lock : Icons.lock_open),
title: const Text('Verrouillage PIN'),
subtitle: Text(
_enabled
? 'Activé. L’app se verrouille à la fermeture ou au changement d’application.'
: 'Désactivé. Tes données locales sont accessibles sans PIN.',
),
),
),
if (_enabled) ...[
const SizedBox(height: 12),
Card(
child: SwitchListTile(
value: _biometricEnabled,
onChanged: _saving || !_biometricAvailable ? null : _toggleBiometrics,
secondary: const Icon(Icons.fingerprint),
title: const Text('Déverrouillage par empreinte'),
subtitle: Text(
_biometricAvailable
? '${_biometricLabel()} Appuie sur l’interrupteur, entre le PIN actuel, puis valide l’empreinte.'
: 'Aucune empreinte/biométrie disponible ou configurée sur ce téléphone.',
),
),
),
],
const SizedBox(height: 16),
if (_enabled) ...[
TextField(
controller: _currentPinController,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: const InputDecoration(
labelText: 'PIN actuel',
border: OutlineInputBorder(),
helperText: 'Uniquement nécessaire pour changer le PIN.',
counterText: '',
),
),
const SizedBox(height: 12),
],
TextField(
controller: _newPinController,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: InputDecoration(
labelText: _enabled ? 'Nouveau PIN' : 'PIN',
border: const OutlineInputBorder(),
helperText: '4 à 8 chiffres.',
counterText: '',
),
),
const SizedBox(height: 12),
TextField(
controller: _confirmPinController,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: const InputDecoration(
labelText: 'Confirmer le PIN',
border: OutlineInputBorder(),
counterText: '',
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _saving ? null : _savePin,
icon: const Icon(Icons.save),
label: Text(_enabled ? 'Changer le PIN' : 'Activer le PIN'),
),
if (_enabled) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _saving ? null : _disablePin,
icon: const Icon(Icons.lock_open),
label: const Text('Désactiver le PIN'),
),
],
if (_message != null) ...[
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(_message!),
),
),
],
const SizedBox(height: 24),
const Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
'Le PIN protège l’accès local à l’application. L’empreinte utilise la sécurité biométrique du téléphone. Le PIN reste le secours si l’empreinte échoue.',
),
),
),
],
),
);
}
}
@@ -0,0 +1,223 @@
import 'account.dart';
class ReconciliationStatus {
final int accountId;
final String accountName;
final double appBalance;
final bool checkedToday;
final double? realBalance;
final double? difference;
ReconciliationStatus({
required this.accountId,
required this.accountName,
required this.appBalance,
required this.checkedToday,
this.realBalance,
this.difference,
});
factory ReconciliationStatus.fromJson(Map<String, dynamic> json) {
return ReconciliationStatus(
accountId: json['account_id'] as int,
accountName: json['account_name'] as String,
appBalance: (json['app_balance'] as num? ?? 0).toDouble(),
checkedToday: json['checked_today'] is int
? (json['checked_today'] as int) == 1
: json['checked_today'] as bool? ?? false,
realBalance: (json['real_balance'] as num?)?.toDouble(),
difference: (json['difference'] as num?)?.toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'account_id': accountId,
'account_name': accountName,
'app_balance': appBalance,
'checked_today': checkedToday,
'real_balance': realBalance,
'difference': difference,
};
}
ReconciliationStatus copyWith({
double? appBalance,
bool? checkedToday,
double? realBalance,
double? difference,
}) {
return ReconciliationStatus(
accountId: accountId,
accountName: accountName,
appBalance: appBalance ?? this.appBalance,
checkedToday: checkedToday ?? this.checkedToday,
realBalance: realBalance ?? this.realBalance,
difference: difference ?? this.difference,
);
}
}
class DashboardData {
final String month;
final double totalBalance;
final double availableAfterReserves;
final double monthlyIncome;
final double monthlyExpenses;
final double monthlyBalance;
final double provisionsCurrentTotal;
final double provisionsTargetTotal;
final double monthlyChargesRemainingTotal;
final double monthlyTransferRemainingTotal;
final double monthlyProvisionOperationRemainingTotal;
// Nouveaux champs backend : échéancier / opérations prévues ponctuelles.
final double scheduledExpensesRemainingTotal;
final double scheduledIncomeRemainingTotal;
final double scheduledTransferRemainingTotal;
final double scheduledProvisionRemainingTotal;
final double scheduledNetRemainingTotal;
final double projectedMonthBalance;
final List<Account> accounts;
final List<ReconciliationStatus> reconciliationsToday;
DashboardData({
required this.month,
required this.totalBalance,
required this.availableAfterReserves,
required this.monthlyIncome,
required this.monthlyExpenses,
required this.monthlyBalance,
required this.provisionsCurrentTotal,
required this.provisionsTargetTotal,
required this.monthlyChargesRemainingTotal,
required this.monthlyTransferRemainingTotal,
required this.monthlyProvisionOperationRemainingTotal,
required this.scheduledExpensesRemainingTotal,
required this.scheduledIncomeRemainingTotal,
required this.scheduledTransferRemainingTotal,
required this.scheduledProvisionRemainingTotal,
required this.scheduledNetRemainingTotal,
required this.projectedMonthBalance,
required this.accounts,
required this.reconciliationsToday,
});
double get plannedExpensesTotal =>
monthlyChargesRemainingTotal + scheduledExpensesRemainingTotal;
double get plannedTransfersTotal =>
monthlyTransferRemainingTotal + scheduledTransferRemainingTotal;
double get plannedProvisionsTotal =>
monthlyProvisionOperationRemainingTotal + scheduledProvisionRemainingTotal;
bool get hasScheduledInfo =>
scheduledExpensesRemainingTotal != 0 ||
scheduledIncomeRemainingTotal != 0 ||
scheduledTransferRemainingTotal != 0 ||
scheduledProvisionRemainingTotal != 0;
factory DashboardData.fromJson(Map<String, dynamic> json) {
final availableRaw = json['available_after_reserves'] ?? json['total_balance'] ?? 0;
final monthlyBalance = (json['monthly_balance'] as num? ?? 0).toDouble();
final scheduledIncome = (json['scheduled_income_remaining_total'] as num? ?? 0).toDouble();
final scheduledExpenses = (json['scheduled_expenses_remaining_total'] as num? ?? 0).toDouble();
return DashboardData(
month: json['month'] as String? ?? '',
totalBalance: (json['total_balance'] as num? ?? 0).toDouble(),
availableAfterReserves: (availableRaw as num).toDouble(),
monthlyIncome: (json['monthly_income'] as num? ?? 0).toDouble(),
monthlyExpenses: (json['monthly_expenses'] as num? ?? 0).toDouble(),
monthlyBalance: monthlyBalance,
provisionsCurrentTotal: (json['provisions_current_total'] as num? ?? 0).toDouble(),
provisionsTargetTotal: (json['provisions_target_total'] as num? ?? 0).toDouble(),
monthlyChargesRemainingTotal: (json['monthly_charges_remaining_total'] as num? ?? 0).toDouble(),
monthlyTransferRemainingTotal: (json['monthly_transfer_remaining_total'] as num? ?? 0).toDouble(),
monthlyProvisionOperationRemainingTotal: (json['monthly_provision_operation_remaining_total'] as num? ?? 0).toDouble(),
scheduledExpensesRemainingTotal: scheduledExpenses,
scheduledIncomeRemainingTotal: scheduledIncome,
scheduledTransferRemainingTotal: (json['scheduled_transfer_remaining_total'] as num? ?? 0).toDouble(),
scheduledProvisionRemainingTotal: (json['scheduled_provision_remaining_total'] as num? ?? 0).toDouble(),
scheduledNetRemainingTotal: (json['scheduled_net_remaining_total'] as num? ?? (scheduledIncome - scheduledExpenses)).toDouble(),
projectedMonthBalance: (json['projected_month_balance'] as num? ?? (monthlyBalance + scheduledIncome - scheduledExpenses)).toDouble(),
accounts: (json['accounts'] as List<dynamic>? ?? [])
.map((e) => Account.fromJson(e as Map<String, dynamic>))
.toList(),
reconciliationsToday: (json['reconciliations_today'] as List<dynamic>? ?? [])
.map((e) => ReconciliationStatus.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'month': month,
'total_balance': totalBalance,
'available_after_reserves': availableAfterReserves,
'monthly_income': monthlyIncome,
'monthly_expenses': monthlyExpenses,
'monthly_balance': monthlyBalance,
'provisions_current_total': provisionsCurrentTotal,
'provisions_target_total': provisionsTargetTotal,
'monthly_charges_remaining_total': monthlyChargesRemainingTotal,
'monthly_transfer_remaining_total': monthlyTransferRemainingTotal,
'monthly_provision_operation_remaining_total': monthlyProvisionOperationRemainingTotal,
'scheduled_expenses_remaining_total': scheduledExpensesRemainingTotal,
'scheduled_income_remaining_total': scheduledIncomeRemainingTotal,
'scheduled_transfer_remaining_total': scheduledTransferRemainingTotal,
'scheduled_provision_remaining_total': scheduledProvisionRemainingTotal,
'scheduled_net_remaining_total': scheduledNetRemainingTotal,
'projected_month_balance': projectedMonthBalance,
'accounts': accounts.map((e) => e.toJson()).toList(),
'reconciliations_today': reconciliationsToday.map((e) => e.toJson()).toList(),
};
}
DashboardData copyWith({
String? month,
double? totalBalance,
double? availableAfterReserves,
double? monthlyIncome,
double? monthlyExpenses,
double? monthlyBalance,
double? provisionsCurrentTotal,
double? provisionsTargetTotal,
double? monthlyChargesRemainingTotal,
double? monthlyTransferRemainingTotal,
double? monthlyProvisionOperationRemainingTotal,
double? scheduledExpensesRemainingTotal,
double? scheduledIncomeRemainingTotal,
double? scheduledTransferRemainingTotal,
double? scheduledProvisionRemainingTotal,
double? scheduledNetRemainingTotal,
double? projectedMonthBalance,
List<Account>? accounts,
List<ReconciliationStatus>? reconciliationsToday,
}) {
return DashboardData(
month: month ?? this.month,
totalBalance: totalBalance ?? this.totalBalance,
availableAfterReserves: availableAfterReserves ?? this.availableAfterReserves,
monthlyIncome: monthlyIncome ?? this.monthlyIncome,
monthlyExpenses: monthlyExpenses ?? this.monthlyExpenses,
monthlyBalance: monthlyBalance ?? this.monthlyBalance,
provisionsCurrentTotal: provisionsCurrentTotal ?? this.provisionsCurrentTotal,
provisionsTargetTotal: provisionsTargetTotal ?? this.provisionsTargetTotal,
monthlyChargesRemainingTotal: monthlyChargesRemainingTotal ?? this.monthlyChargesRemainingTotal,
monthlyTransferRemainingTotal: monthlyTransferRemainingTotal ?? this.monthlyTransferRemainingTotal,
monthlyProvisionOperationRemainingTotal: monthlyProvisionOperationRemainingTotal ?? this.monthlyProvisionOperationRemainingTotal,
scheduledExpensesRemainingTotal: scheduledExpensesRemainingTotal ?? this.scheduledExpensesRemainingTotal,
scheduledIncomeRemainingTotal: scheduledIncomeRemainingTotal ?? this.scheduledIncomeRemainingTotal,
scheduledTransferRemainingTotal: scheduledTransferRemainingTotal ?? this.scheduledTransferRemainingTotal,
scheduledProvisionRemainingTotal: scheduledProvisionRemainingTotal ?? this.scheduledProvisionRemainingTotal,
scheduledNetRemainingTotal: scheduledNetRemainingTotal ?? this.scheduledNetRemainingTotal,
projectedMonthBalance: projectedMonthBalance ?? this.projectedMonthBalance,
accounts: accounts ?? this.accounts,
reconciliationsToday: reconciliationsToday ?? this.reconciliationsToday,
);
}
}
@@ -0,0 +1,758 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/account.dart';
import '../models/category.dart';
import '../models/dashboard.dart';
import '../models/transaction_item.dart';
import '../services/sync_service.dart';
import 'quick_transaction_screen.dart';
import 'settings_screen.dart';
import 'sync_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key, required this.syncService});
final SyncService syncService;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
DashboardData? _dashboard;
List<Account> _accounts = [];
List<Category> _categories = [];
List<TransactionItem> _transactions = [];
int _pendingCount = 0;
DateTime? _lastSyncAt;
bool _isOnline = false;
bool _loading = true;
bool _syncing = false;
String? _message;
final _money = NumberFormat.currency(locale: 'fr_FR', symbol: 'MAD ', decimalDigits: 2);
final _date = DateFormat('dd/MM/yyyy HH:mm', 'fr_FR');
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
try {
final local = await widget.syncService.loadLocalSnapshot(
isOnline: false,
message: 'Données locales chargées.',
);
_applySnapshot(local, loading: false);
await _synchronize();
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_message = 'Erreur locale : $e';
});
}
}
void _applySnapshot(SyncSnapshot snapshot, {bool? loading}) {
if (!mounted) return;
setState(() {
_dashboard = snapshot.dashboard;
_accounts = snapshot.accounts;
_categories = snapshot.categories;
_transactions = snapshot.transactions;
_pendingCount = snapshot.pendingCount;
_lastSyncAt = snapshot.lastSyncAt;
_isOnline = snapshot.isOnline;
_message = snapshot.message;
if (loading != null) _loading = loading;
});
}
Future<void> _synchronize() async {
if (_syncing) return;
setState(() {
_syncing = true;
_message = 'Synchronisation...';
});
try {
final snapshot = await widget.syncService.synchronize();
if (!mounted) return;
_applySnapshot(snapshot, loading: false);
} catch (e) {
if (!mounted) return;
setState(() {
_message = 'Erreur de synchronisation : $e';
_loading = false;
});
} finally {
if (mounted) {
setState(() => _syncing = false);
}
}
}
Future<void> _reloadLocalAfterSave() async {
try {
final snapshot = await widget.syncService.loadLocalSnapshot(
isOnline: _isOnline,
message: _isOnline
? 'Opération enregistrée.'
: 'Opération en attente de synchronisation.',
);
_applySnapshot(snapshot, loading: false);
} catch (e) {
if (!mounted) return;
setState(() {
_message = 'Opération enregistrée localement, mais rechargement écran échoué : $e';
_loading = false;
});
}
}
Future<void> _openQuickAdd() async {
if (_accounts.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Crée d’abord au moins un compte dans la version web, puis synchronise.')),
);
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => QuickTransactionScreen(
syncService: widget.syncService,
accounts: _accounts,
categories: _categories,
onSaved: _reloadLocalAfterSave,
),
),
);
}
Future<void> _openSyncScreen() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SyncScreen(syncService: widget.syncService),
),
);
final snapshot = await widget.syncService.loadLocalSnapshot(
isOnline: _isOnline,
message: null,
);
_applySnapshot(snapshot, loading: false);
}
Future<void> _openSettings() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SettingsScreen(apiService: widget.syncService.apiService),
),
);
await _synchronize();
}
@override
Widget build(BuildContext context) {
final dashboard = _dashboard;
return Scaffold(
appBar: AppBar(
title: const Text('FIN65'),
actions: [
IconButton(
tooltip: 'Page synchronisation',
onPressed: _openSyncScreen,
icon: Badge(
isLabelVisible: _pendingCount > 0,
label: Text('$_pendingCount'),
child: const Icon(Icons.cloud_sync),
),
),
IconButton(
tooltip: 'Synchroniser',
onPressed: _syncing ? null : _synchronize,
icon: _syncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
),
IconButton(onPressed: _openSettings, icon: const Icon(Icons.settings)),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _openQuickAdd,
icon: const Icon(Icons.add),
label: const Text('Ajouter'),
),
body: RefreshIndicator(
onRefresh: _synchronize,
child: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
_SyncBanner(
isOnline: _isOnline,
pendingCount: _pendingCount,
lastSyncAt: _lastSyncAt,
formatter: _date,
message: _message,
),
const SizedBox(height: 12),
if (dashboard != null) ...[
_DashboardCards(
dashboard: dashboard,
money: _money,
hasPending: _pendingCount > 0,
),
const SizedBox(height: 16),
] else
const _EmptyCacheCard(),
_SectionTitle(title: 'Comptes', action: _pendingCount > 0 ? 'Solde estimé' : 'Solde app'),
const SizedBox(height: 8),
if (_accounts.isEmpty)
const Card(child: ListTile(title: Text('Aucun compte en cache. Synchronise avec le backend.')))
else
..._accounts.map((a) => _AccountTile(account: a, money: _money)),
const SizedBox(height: 16),
_SectionTitle(title: 'Dernières transactions'),
const SizedBox(height: 8),
if (_transactions.isEmpty)
const Card(child: ListTile(title: Text('Aucune transaction en cache.')))
else
..._transactions.map(
(t) => _TransactionTile(
transaction: t,
accounts: _accounts,
categories: _categories,
money: _money,
),
),
const SizedBox(height: 90),
],
),
),
);
}
}
class _SyncBanner extends StatelessWidget {
const _SyncBanner({
required this.isOnline,
required this.pendingCount,
required this.lastSyncAt,
required this.formatter,
this.message,
});
final bool isOnline;
final int pendingCount;
final DateTime? lastSyncAt;
final DateFormat formatter;
final String? message;
@override
Widget build(BuildContext context) {
final title = isOnline ? 'Connecté au backend' : 'Mode hors ligne';
final subtitleParts = <String>[];
if (lastSyncAt != null) {
subtitleParts.add('Dernière synchro : ${formatter.format(lastSyncAt!.toLocal())}');
} else {
subtitleParts.add('Aucune synchronisation réussie');
}
if (pendingCount > 0) {
subtitleParts.add('$pendingCount opération(s) en attente');
}
if (message != null && message!.isNotEmpty) {
subtitleParts.add(message!);
}
return Card(
child: ListTile(
leading: Icon(isOnline ? Icons.cloud_done : Icons.cloud_off),
title: Text(title),
subtitle: Text(subtitleParts.join('\n')),
),
);
}
}
class _EmptyCacheCard extends StatelessWidget {
const _EmptyCacheCard();
@override
Widget build(BuildContext context) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Aucune donnée locale. Lance le backend PC une première fois, configure l’URL API, puis synchronise.',
),
),
);
}
}
class _DashboardCards extends StatelessWidget {
const _DashboardCards({
required this.dashboard,
required this.money,
required this.hasPending,
});
final DashboardData dashboard;
final NumberFormat money;
final bool hasPending;
@override
Widget build(BuildContext context) {
final labelSuffix = hasPending ? ' estimé' : '';
return Column(
children: [
_MetricCard(
title: 'Disponible réel$labelSuffix',
value: money.format(dashboard.availableAfterReserves),
icon: Icons.account_balance_wallet,
backgroundColor: const Color(0xFFE8F5E9),
accentColor: const Color(0xFF2E7D32),
),
const SizedBox(height: 8),
_MetricCard(
title: 'Balance prévisionnelle du mois',
value: money.format(dashboard.projectedMonthBalance),
icon: Icons.insights,
backgroundColor: const Color(0xFFE3F2FD),
accentColor: const Color(0xFF1565C0),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MetricCard(
title: 'Solde total$labelSuffix',
value: money.format(dashboard.totalBalance),
icon: Icons.savings,
compact: true,
backgroundColor: const Color(0xFFF3E5F5),
accentColor: const Color(0xFF6A1B9A),
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricCard(
title: 'Balance réelle',
value: money.format(dashboard.monthlyBalance),
icon: Icons.trending_up,
compact: true,
backgroundColor: dashboard.monthlyBalance >= 0
? const Color(0xFFE8F5E9)
: const Color(0xFFFFEBEE),
accentColor: dashboard.monthlyBalance >= 0
? const Color(0xFF2E7D32)
: const Color(0xFFC62828),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MetricCard(
title: 'Revenus réels',
value: money.format(dashboard.monthlyIncome),
icon: Icons.arrow_downward,
compact: true,
backgroundColor: const Color(0xFFE8F5E9),
accentColor: const Color(0xFF2E7D32),
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricCard(
title: 'Dépenses réelles',
value: money.format(dashboard.monthlyExpenses),
icon: Icons.arrow_upward,
compact: true,
backgroundColor: const Color(0xFFFFEBEE),
accentColor: const Color(0xFFC62828),
),
),
],
),
const SizedBox(height: 8),
_PlannedInfoCard(dashboard: dashboard, money: money),
],
);
}
}
class _PlannedInfoCard extends StatelessWidget {
const _PlannedInfoCard({
required this.dashboard,
required this.money,
});
final DashboardData dashboard;
final NumberFormat money;
@override
Widget build(BuildContext context) {
final rows = <_InfoRow>[
_InfoRow(
'Dépenses prévues restantes',
dashboard.plannedExpensesTotal,
Icons.event_busy,
const Color(0xFFFFEBEE),
const Color(0xFFC62828),
),
_InfoRow(
'Revenus prévus échéancier',
dashboard.scheduledIncomeRemainingTotal,
Icons.event_available,
const Color(0xFFE8F5E9),
const Color(0xFF2E7D32),
),
_InfoRow(
'Dépenses échéancier',
dashboard.scheduledExpensesRemainingTotal,
Icons.calendar_month,
const Color(0xFFFFF3E0),
const Color(0xFFEF6C00),
),
_InfoRow(
'Transferts prévus',
dashboard.plannedTransfersTotal,
Icons.swap_horiz,
const Color(0xFFE3F2FD),
const Color(0xFF1565C0),
),
_InfoRow(
'Provisions prévues',
dashboard.plannedProvisionsTotal,
Icons.inventory_2,
const Color(0xFFF3E5F5),
const Color(0xFF6A1B9A),
),
];
return Card(
color: const Color(0xFFFAFAFA),
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.event_note, color: Color(0xFF1565C0)),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Prévisionnel du mois',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'Inclut les opérations mensuelles et l’échéancier ponctuel du backend.',
style: Theme.of(context).textTheme.bodySmall,
),
const Divider(height: 20),
...rows.map(
(row) => Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
decoration: BoxDecoration(
color: row.backgroundColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: row.accentColor.withOpacity(0.22)),
),
child: Row(
children: [
Icon(row.icon, size: 20, color: row.accentColor),
const SizedBox(width: 10),
Expanded(
child: Text(
row.label,
style: TextStyle(
color: row.accentColor,
fontWeight: FontWeight.w700,
),
),
),
Text(
money.format(row.value),
style: TextStyle(
color: row.accentColor,
fontWeight: FontWeight.w900,
),
),
],
),
),
),
],
),
),
);
}
}
class _InfoRow {
const _InfoRow(
this.label,
this.value,
this.icon,
this.backgroundColor,
this.accentColor,
);
final String label;
final double value;
final IconData icon;
final Color backgroundColor;
final Color accentColor;
}
class _MetricCard extends StatelessWidget {
const _MetricCard({
required this.title,
required this.value,
required this.icon,
required this.backgroundColor,
required this.accentColor,
this.compact = false,
});
final String title;
final String value;
final IconData icon;
final Color backgroundColor;
final Color accentColor;
final bool compact;
@override
Widget build(BuildContext context) {
return Card(
color: backgroundColor,
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: EdgeInsets.all(compact ? 12 : 16),
child: Row(
children: [
Container(
width: compact ? 34 : 42,
height: compact ? 34 : 42,
decoration: BoxDecoration(
color: accentColor.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, size: compact ? 21 : 27, color: accentColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: accentColor,
fontWeight: FontWeight.w700,
),
),
Text(
value,
style: (compact
? Theme.of(context).textTheme.titleMedium
: Theme.of(context).textTheme.headlineSmall)
?.copyWith(
color: accentColor,
fontWeight: FontWeight.w900,
),
),
],
),
),
],
),
),
);
}
}
class _MetricCard extends StatelessWidget {
const _MetricCard({
required this.title,
required this.value,
required this.icon,
this.compact = false,
});
final String title;
final String value;
final IconData icon;
final bool compact;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: EdgeInsets.all(compact ? 12 : 16),
child: Row(
children: [
Icon(icon, size: compact ? 24 : 32),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodySmall),
Text(
value,
style: compact
? Theme.of(context).textTheme.titleMedium
: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
],
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title, this.action});
final String title;
final String? action;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
if (action != null) Text(action!, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}
class _AccountTile extends StatelessWidget {
const _AccountTile({required this.account, required this.money});
final Account account;
final NumberFormat money;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: const Icon(Icons.account_balance),
title: Text(account.name),
subtitle: Text(account.type),
trailing: Text(
money.format(account.appBalance),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
);
}
}
class _TransactionTile extends StatelessWidget {
const _TransactionTile({
required this.transaction,
required this.accounts,
required this.categories,
required this.money,
});
final TransactionItem transaction;
final List<Account> accounts;
final List<Category> categories;
final NumberFormat money;
Account? _findAccount(int? id) {
if (id == null) return null;
for (final account in accounts) {
if (account.id == id) return account;
}
return null;
}
Category? _findCategory(int? id) {
if (id == null) return null;
for (final category in categories) {
if (category.id == id) return category;
}
return null;
}
@override
Widget build(BuildContext context) {
final account = _findAccount(transaction.accountId);
final target = _findAccount(transaction.targetAccountId);
final category = _findCategory(transaction.categoryId);
final typeLabel = switch (transaction.type) {
'expense' => 'Dépense',
'income' => 'Recette',
'transfer' => 'Transfert',
_ => transaction.type,
};
final accountLabel = transaction.type == 'transfer'
? '${account?.name ?? transaction.accountId} → ${target?.name ?? transaction.targetAccountId}'
: account?.name ?? 'Compte ${transaction.accountId}';
return Card(
child: ListTile(
leading: Icon(transaction.isPending ? Icons.schedule_send : Icons.receipt_long),
title: Text(transaction.label),
subtitle: Text(
'${transaction.isPending ? 'EN ATTENTE • ' : ''}$typeLabel • $accountLabel${category == null ? '' : ' • ${category.name}'}',
),
trailing: Text(
money.format(transaction.amount),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
);
}
}
@@ -0,0 +1,223 @@
import 'account.dart';
class ReconciliationStatus {
final int accountId;
final String accountName;
final double appBalance;
final bool checkedToday;
final double? realBalance;
final double? difference;
ReconciliationStatus({
required this.accountId,
required this.accountName,
required this.appBalance,
required this.checkedToday,
this.realBalance,
this.difference,
});
factory ReconciliationStatus.fromJson(Map<String, dynamic> json) {
return ReconciliationStatus(
accountId: json['account_id'] as int,
accountName: json['account_name'] as String,
appBalance: (json['app_balance'] as num? ?? 0).toDouble(),
checkedToday: json['checked_today'] is int
? (json['checked_today'] as int) == 1
: json['checked_today'] as bool? ?? false,
realBalance: (json['real_balance'] as num?)?.toDouble(),
difference: (json['difference'] as num?)?.toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'account_id': accountId,
'account_name': accountName,
'app_balance': appBalance,
'checked_today': checkedToday,
'real_balance': realBalance,
'difference': difference,
};
}
ReconciliationStatus copyWith({
double? appBalance,
bool? checkedToday,
double? realBalance,
double? difference,
}) {
return ReconciliationStatus(
accountId: accountId,
accountName: accountName,
appBalance: appBalance ?? this.appBalance,
checkedToday: checkedToday ?? this.checkedToday,
realBalance: realBalance ?? this.realBalance,
difference: difference ?? this.difference,
);
}
}
class DashboardData {
final String month;
final double totalBalance;
final double availableAfterReserves;
final double monthlyIncome;
final double monthlyExpenses;
final double monthlyBalance;
final double provisionsCurrentTotal;
final double provisionsTargetTotal;
final double monthlyChargesRemainingTotal;
final double monthlyTransferRemainingTotal;
final double monthlyProvisionOperationRemainingTotal;
// Nouveaux champs backend : échéancier / opérations prévues ponctuelles.
final double scheduledExpensesRemainingTotal;
final double scheduledIncomeRemainingTotal;
final double scheduledTransferRemainingTotal;
final double scheduledProvisionRemainingTotal;
final double scheduledNetRemainingTotal;
final double projectedMonthBalance;
final List<Account> accounts;
final List<ReconciliationStatus> reconciliationsToday;
DashboardData({
required this.month,
required this.totalBalance,
required this.availableAfterReserves,
required this.monthlyIncome,
required this.monthlyExpenses,
required this.monthlyBalance,
required this.provisionsCurrentTotal,
required this.provisionsTargetTotal,
required this.monthlyChargesRemainingTotal,
required this.monthlyTransferRemainingTotal,
required this.monthlyProvisionOperationRemainingTotal,
required this.scheduledExpensesRemainingTotal,
required this.scheduledIncomeRemainingTotal,
required this.scheduledTransferRemainingTotal,
required this.scheduledProvisionRemainingTotal,
required this.scheduledNetRemainingTotal,
required this.projectedMonthBalance,
required this.accounts,
required this.reconciliationsToday,
});
double get plannedExpensesTotal =>
monthlyChargesRemainingTotal + scheduledExpensesRemainingTotal;
double get plannedTransfersTotal =>
monthlyTransferRemainingTotal + scheduledTransferRemainingTotal;
double get plannedProvisionsTotal =>
monthlyProvisionOperationRemainingTotal + scheduledProvisionRemainingTotal;
bool get hasScheduledInfo =>
scheduledExpensesRemainingTotal != 0 ||
scheduledIncomeRemainingTotal != 0 ||
scheduledTransferRemainingTotal != 0 ||
scheduledProvisionRemainingTotal != 0;
factory DashboardData.fromJson(Map<String, dynamic> json) {
final availableRaw = json['available_after_reserves'] ?? json['total_balance'] ?? 0;
final monthlyBalance = (json['monthly_balance'] as num? ?? 0).toDouble();
final scheduledIncome = (json['scheduled_income_remaining_total'] as num? ?? 0).toDouble();
final scheduledExpenses = (json['scheduled_expenses_remaining_total'] as num? ?? 0).toDouble();
return DashboardData(
month: json['month'] as String? ?? '',
totalBalance: (json['total_balance'] as num? ?? 0).toDouble(),
availableAfterReserves: (availableRaw as num).toDouble(),
monthlyIncome: (json['monthly_income'] as num? ?? 0).toDouble(),
monthlyExpenses: (json['monthly_expenses'] as num? ?? 0).toDouble(),
monthlyBalance: monthlyBalance,
provisionsCurrentTotal: (json['provisions_current_total'] as num? ?? 0).toDouble(),
provisionsTargetTotal: (json['provisions_target_total'] as num? ?? 0).toDouble(),
monthlyChargesRemainingTotal: (json['monthly_charges_remaining_total'] as num? ?? 0).toDouble(),
monthlyTransferRemainingTotal: (json['monthly_transfer_remaining_total'] as num? ?? 0).toDouble(),
monthlyProvisionOperationRemainingTotal: (json['monthly_provision_operation_remaining_total'] as num? ?? 0).toDouble(),
scheduledExpensesRemainingTotal: scheduledExpenses,
scheduledIncomeRemainingTotal: scheduledIncome,
scheduledTransferRemainingTotal: (json['scheduled_transfer_remaining_total'] as num? ?? 0).toDouble(),
scheduledProvisionRemainingTotal: (json['scheduled_provision_remaining_total'] as num? ?? 0).toDouble(),
scheduledNetRemainingTotal: (json['scheduled_net_remaining_total'] as num? ?? (scheduledIncome - scheduledExpenses)).toDouble(),
projectedMonthBalance: (json['projected_month_balance'] as num? ?? (monthlyBalance + scheduledIncome - scheduledExpenses)).toDouble(),
accounts: (json['accounts'] as List<dynamic>? ?? [])
.map((e) => Account.fromJson(e as Map<String, dynamic>))
.toList(),
reconciliationsToday: (json['reconciliations_today'] as List<dynamic>? ?? [])
.map((e) => ReconciliationStatus.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'month': month,
'total_balance': totalBalance,
'available_after_reserves': availableAfterReserves,
'monthly_income': monthlyIncome,
'monthly_expenses': monthlyExpenses,
'monthly_balance': monthlyBalance,
'provisions_current_total': provisionsCurrentTotal,
'provisions_target_total': provisionsTargetTotal,
'monthly_charges_remaining_total': monthlyChargesRemainingTotal,
'monthly_transfer_remaining_total': monthlyTransferRemainingTotal,
'monthly_provision_operation_remaining_total': monthlyProvisionOperationRemainingTotal,
'scheduled_expenses_remaining_total': scheduledExpensesRemainingTotal,
'scheduled_income_remaining_total': scheduledIncomeRemainingTotal,
'scheduled_transfer_remaining_total': scheduledTransferRemainingTotal,
'scheduled_provision_remaining_total': scheduledProvisionRemainingTotal,
'scheduled_net_remaining_total': scheduledNetRemainingTotal,
'projected_month_balance': projectedMonthBalance,
'accounts': accounts.map((e) => e.toJson()).toList(),
'reconciliations_today': reconciliationsToday.map((e) => e.toJson()).toList(),
};
}
DashboardData copyWith({
String? month,
double? totalBalance,
double? availableAfterReserves,
double? monthlyIncome,
double? monthlyExpenses,
double? monthlyBalance,
double? provisionsCurrentTotal,
double? provisionsTargetTotal,
double? monthlyChargesRemainingTotal,
double? monthlyTransferRemainingTotal,
double? monthlyProvisionOperationRemainingTotal,
double? scheduledExpensesRemainingTotal,
double? scheduledIncomeRemainingTotal,
double? scheduledTransferRemainingTotal,
double? scheduledProvisionRemainingTotal,
double? scheduledNetRemainingTotal,
double? projectedMonthBalance,
List<Account>? accounts,
List<ReconciliationStatus>? reconciliationsToday,
}) {
return DashboardData(
month: month ?? this.month,
totalBalance: totalBalance ?? this.totalBalance,
availableAfterReserves: availableAfterReserves ?? this.availableAfterReserves,
monthlyIncome: monthlyIncome ?? this.monthlyIncome,
monthlyExpenses: monthlyExpenses ?? this.monthlyExpenses,
monthlyBalance: monthlyBalance ?? this.monthlyBalance,
provisionsCurrentTotal: provisionsCurrentTotal ?? this.provisionsCurrentTotal,
provisionsTargetTotal: provisionsTargetTotal ?? this.provisionsTargetTotal,
monthlyChargesRemainingTotal: monthlyChargesRemainingTotal ?? this.monthlyChargesRemainingTotal,
monthlyTransferRemainingTotal: monthlyTransferRemainingTotal ?? this.monthlyTransferRemainingTotal,
monthlyProvisionOperationRemainingTotal: monthlyProvisionOperationRemainingTotal ?? this.monthlyProvisionOperationRemainingTotal,
scheduledExpensesRemainingTotal: scheduledExpensesRemainingTotal ?? this.scheduledExpensesRemainingTotal,
scheduledIncomeRemainingTotal: scheduledIncomeRemainingTotal ?? this.scheduledIncomeRemainingTotal,
scheduledTransferRemainingTotal: scheduledTransferRemainingTotal ?? this.scheduledTransferRemainingTotal,
scheduledProvisionRemainingTotal: scheduledProvisionRemainingTotal ?? this.scheduledProvisionRemainingTotal,
scheduledNetRemainingTotal: scheduledNetRemainingTotal ?? this.scheduledNetRemainingTotal,
projectedMonthBalance: projectedMonthBalance ?? this.projectedMonthBalance,
accounts: accounts ?? this.accounts,
reconciliationsToday: reconciliationsToday ?? this.reconciliationsToday,
);
}
}
@@ -0,0 +1,715 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/account.dart';
import '../models/category.dart';
import '../models/dashboard.dart';
import '../models/transaction_item.dart';
import '../services/sync_service.dart';
import 'quick_transaction_screen.dart';
import 'settings_screen.dart';
import 'sync_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key, required this.syncService});
final SyncService syncService;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
DashboardData? _dashboard;
List<Account> _accounts = [];
List<Category> _categories = [];
List<TransactionItem> _transactions = [];
int _pendingCount = 0;
DateTime? _lastSyncAt;
bool _isOnline = false;
bool _loading = true;
bool _syncing = false;
String? _message;
final _money = NumberFormat.currency(locale: 'fr_FR', symbol: 'MAD ', decimalDigits: 2);
final _date = DateFormat('dd/MM/yyyy HH:mm', 'fr_FR');
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
try {
final local = await widget.syncService.loadLocalSnapshot(
isOnline: false,
message: 'Données locales chargées.',
);
_applySnapshot(local, loading: false);
await _synchronize();
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_message = 'Erreur locale : $e';
});
}
}
void _applySnapshot(SyncSnapshot snapshot, {bool? loading}) {
if (!mounted) return;
setState(() {
_dashboard = snapshot.dashboard;
_accounts = snapshot.accounts;
_categories = snapshot.categories;
_transactions = snapshot.transactions;
_pendingCount = snapshot.pendingCount;
_lastSyncAt = snapshot.lastSyncAt;
_isOnline = snapshot.isOnline;
_message = snapshot.message;
if (loading != null) _loading = loading;
});
}
Future<void> _synchronize() async {
if (_syncing) return;
setState(() {
_syncing = true;
_message = 'Synchronisation...';
});
try {
final snapshot = await widget.syncService.synchronize();
if (!mounted) return;
_applySnapshot(snapshot, loading: false);
} catch (e) {
if (!mounted) return;
setState(() {
_message = 'Erreur de synchronisation : $e';
_loading = false;
});
} finally {
if (mounted) {
setState(() => _syncing = false);
}
}
}
Future<void> _reloadLocalAfterSave() async {
try {
final snapshot = await widget.syncService.loadLocalSnapshot(
isOnline: _isOnline,
message: _isOnline
? 'Opération enregistrée.'
: 'Opération en attente de synchronisation.',
);
_applySnapshot(snapshot, loading: false);
} catch (e) {
if (!mounted) return;
setState(() {
_message = 'Opération enregistrée localement, mais rechargement écran échoué : $e';
_loading = false;
});
}
}
Future<void> _openQuickAdd() async {
if (_accounts.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Crée d’abord au moins un compte dans la version web, puis synchronise.')),
);
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => QuickTransactionScreen(
syncService: widget.syncService,
accounts: _accounts,
categories: _categories,
onSaved: _reloadLocalAfterSave,
),
),
);
}
Future<void> _openSyncScreen() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SyncScreen(syncService: widget.syncService),
),
);
final snapshot = await widget.syncService.loadLocalSnapshot(
isOnline: _isOnline,
message: null,
);
_applySnapshot(snapshot, loading: false);
}
Future<void> _openSettings() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SettingsScreen(apiService: widget.syncService.apiService),
),
);
await _synchronize();
}
@override
Widget build(BuildContext context) {
final dashboard = _dashboard;
return Scaffold(
appBar: AppBar(
title: const Text('FIN65'),
actions: [
IconButton(
tooltip: 'Page synchronisation',
onPressed: _openSyncScreen,
icon: Badge(
isLabelVisible: _pendingCount > 0,
label: Text('$_pendingCount'),
child: const Icon(Icons.cloud_sync),
),
),
IconButton(
tooltip: 'Synchroniser',
onPressed: _syncing ? null : _synchronize,
icon: _syncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
),
IconButton(onPressed: _openSettings, icon: const Icon(Icons.settings)),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _openQuickAdd,
icon: const Icon(Icons.add),
label: const Text('Ajouter'),
),
body: RefreshIndicator(
onRefresh: _synchronize,
child: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
_SyncBanner(
isOnline: _isOnline,
pendingCount: _pendingCount,
lastSyncAt: _lastSyncAt,
formatter: _date,
message: _message,
),
const SizedBox(height: 12),
if (dashboard != null) ...[
_DashboardCards(
dashboard: dashboard,
money: _money,
hasPending: _pendingCount > 0,
),
const SizedBox(height: 16),
] else
const _EmptyCacheCard(),
_SectionTitle(title: 'Comptes', action: _pendingCount > 0 ? 'Solde estimé' : 'Solde app'),
const SizedBox(height: 8),
if (_accounts.isEmpty)
const Card(child: ListTile(title: Text('Aucun compte en cache. Synchronise avec le backend.')))
else
..._accounts.map((a) => _AccountTile(account: a, money: _money)),
const SizedBox(height: 16),
_SectionTitle(title: 'Dernières transactions'),
const SizedBox(height: 8),
if (_transactions.isEmpty)
const Card(child: ListTile(title: Text('Aucune transaction en cache.')))
else
..._transactions.map(
(t) => _TransactionTile(
transaction: t,
accounts: _accounts,
categories: _categories,
money: _money,
),
),
const SizedBox(height: 90),
],
),
),
);
}
}
class _SyncBanner extends StatelessWidget {
const _SyncBanner({
required this.isOnline,
required this.pendingCount,
required this.lastSyncAt,
required this.formatter,
this.message,
});
final bool isOnline;
final int pendingCount;
final DateTime? lastSyncAt;
final DateFormat formatter;
final String? message;
@override
Widget build(BuildContext context) {
final title = isOnline ? 'Connecté au backend' : 'Mode hors ligne';
final subtitleParts = <String>[];
if (lastSyncAt != null) {
subtitleParts.add('Dernière synchro : ${formatter.format(lastSyncAt!.toLocal())}');
} else {
subtitleParts.add('Aucune synchronisation réussie');
}
if (pendingCount > 0) {
subtitleParts.add('$pendingCount opération(s) en attente');
}
if (message != null && message!.isNotEmpty) {
subtitleParts.add(message!);
}
return Card(
child: ListTile(
leading: Icon(isOnline ? Icons.cloud_done : Icons.cloud_off),
title: Text(title),
subtitle: Text(subtitleParts.join('\n')),
),
);
}
}
class _EmptyCacheCard extends StatelessWidget {
const _EmptyCacheCard();
@override
Widget build(BuildContext context) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Aucune donnée locale. Lance le backend PC une première fois, configure l’URL API, puis synchronise.',
),
),
);
}
}
class _DashboardCards extends StatelessWidget {
const _DashboardCards({
required this.dashboard,
required this.money,
required this.hasPending,
});
final DashboardData dashboard;
final NumberFormat money;
final bool hasPending;
@override
Widget build(BuildContext context) {
final labelSuffix = hasPending ? ' estimé' : '';
return Column(
children: [
_MetricCard(
title: 'Disponible réel$labelSuffix',
value: money.format(dashboard.availableAfterReserves),
icon: Icons.account_balance_wallet,
backgroundColor: const Color(0xFFE8F5E9),
accentColor: const Color(0xFF2E7D32),
),
const SizedBox(height: 8),
_MetricCard(
title: 'Balance prévisionnelle du mois',
value: money.format(dashboard.projectedMonthBalance),
icon: Icons.insights,
backgroundColor: const Color(0xFFE3F2FD),
accentColor: const Color(0xFF1565C0),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MetricCard(
title: 'Solde total$labelSuffix',
value: money.format(dashboard.totalBalance),
icon: Icons.savings,
compact: true,
backgroundColor: const Color(0xFFF3E5F5),
accentColor: const Color(0xFF6A1B9A),
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricCard(
title: 'Balance réelle',
value: money.format(dashboard.monthlyBalance),
icon: Icons.trending_up,
compact: true,
backgroundColor: dashboard.monthlyBalance >= 0
? const Color(0xFFE8F5E9)
: const Color(0xFFFFEBEE),
accentColor: dashboard.monthlyBalance >= 0
? const Color(0xFF2E7D32)
: const Color(0xFFC62828),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MetricCard(
title: 'Revenus réels',
value: money.format(dashboard.monthlyIncome),
icon: Icons.arrow_downward,
compact: true,
backgroundColor: const Color(0xFFE8F5E9),
accentColor: const Color(0xFF2E7D32),
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricCard(
title: 'Dépenses réelles',
value: money.format(dashboard.monthlyExpenses),
icon: Icons.arrow_upward,
compact: true,
backgroundColor: const Color(0xFFFFEBEE),
accentColor: const Color(0xFFC62828),
),
),
],
),
const SizedBox(height: 8),
_PlannedInfoCard(dashboard: dashboard, money: money),
],
);
}
}
class _PlannedInfoCard extends StatelessWidget {
const _PlannedInfoCard({
required this.dashboard,
required this.money,
});
final DashboardData dashboard;
final NumberFormat money;
@override
Widget build(BuildContext context) {
final rows = <_InfoRow>[
_InfoRow(
'Dépenses prévues restantes',
dashboard.plannedExpensesTotal,
Icons.event_busy,
const Color(0xFFFFEBEE),
const Color(0xFFC62828),
),
_InfoRow(
'Revenus prévus échéancier',
dashboard.scheduledIncomeRemainingTotal,
Icons.event_available,
const Color(0xFFE8F5E9),
const Color(0xFF2E7D32),
),
_InfoRow(
'Dépenses échéancier',
dashboard.scheduledExpensesRemainingTotal,
Icons.calendar_month,
const Color(0xFFFFF3E0),
const Color(0xFFEF6C00),
),
_InfoRow(
'Transferts prévus',
dashboard.plannedTransfersTotal,
Icons.swap_horiz,
const Color(0xFFE3F2FD),
const Color(0xFF1565C0),
),
_InfoRow(
'Provisions prévues',
dashboard.plannedProvisionsTotal,
Icons.inventory_2,
const Color(0xFFF3E5F5),
const Color(0xFF6A1B9A),
),
];
return Card(
color: const Color(0xFFFAFAFA),
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.event_note, color: Color(0xFF1565C0)),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Prévisionnel du mois',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'Inclut les opérations mensuelles et l’échéancier ponctuel du backend.',
style: Theme.of(context).textTheme.bodySmall,
),
const Divider(height: 20),
...rows.map(
(row) => Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
decoration: BoxDecoration(
color: row.backgroundColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: row.accentColor.withOpacity(0.22)),
),
child: Row(
children: [
Icon(row.icon, size: 20, color: row.accentColor),
const SizedBox(width: 10),
Expanded(
child: Text(
row.label,
style: TextStyle(
color: row.accentColor,
fontWeight: FontWeight.w700,
),
),
),
Text(
money.format(row.value),
style: TextStyle(
color: row.accentColor,
fontWeight: FontWeight.w900,
),
),
],
),
),
),
],
),
),
);
}
}
class _InfoRow {
const _InfoRow(
this.label,
this.value,
this.icon,
this.backgroundColor,
this.accentColor,
);
final String label;
final double value;
final IconData icon;
final Color backgroundColor;
final Color accentColor;
}
class _MetricCard extends StatelessWidget {
const _MetricCard({
required this.title,
required this.value,
required this.icon,
required this.backgroundColor,
required this.accentColor,
this.compact = false,
});
final String title;
final String value;
final IconData icon;
final Color backgroundColor;
final Color accentColor;
final bool compact;
@override
Widget build(BuildContext context) {
return Card(
color: backgroundColor,
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: EdgeInsets.all(compact ? 12 : 16),
child: Row(
children: [
Container(
width: compact ? 34 : 42,
height: compact ? 34 : 42,
decoration: BoxDecoration(
color: accentColor.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, size: compact ? 21 : 27, color: accentColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: accentColor,
fontWeight: FontWeight.w700,
),
),
Text(
value,
style: (compact
? Theme.of(context).textTheme.titleMedium
: Theme.of(context).textTheme.headlineSmall)
?.copyWith(
color: accentColor,
fontWeight: FontWeight.w900,
),
),
],
),
),
],
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title, this.action});
final String title;
final String? action;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
if (action != null) Text(action!, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}
class _AccountTile extends StatelessWidget {
const _AccountTile({required this.account, required this.money});
final Account account;
final NumberFormat money;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: const Icon(Icons.account_balance),
title: Text(account.name),
subtitle: Text(account.type),
trailing: Text(
money.format(account.appBalance),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
);
}
}
class _TransactionTile extends StatelessWidget {
const _TransactionTile({
required this.transaction,
required this.accounts,
required this.categories,
required this.money,
});
final TransactionItem transaction;
final List<Account> accounts;
final List<Category> categories;
final NumberFormat money;
Account? _findAccount(int? id) {
if (id == null) return null;
for (final account in accounts) {
if (account.id == id) return account;
}
return null;
}
Category? _findCategory(int? id) {
if (id == null) return null;
for (final category in categories) {
if (category.id == id) return category;
}
return null;
}
@override
Widget build(BuildContext context) {
final account = _findAccount(transaction.accountId);
final target = _findAccount(transaction.targetAccountId);
final category = _findCategory(transaction.categoryId);
final typeLabel = switch (transaction.type) {
'expense' => 'Dépense',
'income' => 'Recette',
'transfer' => 'Transfert',
_ => transaction.type,
};
final accountLabel = transaction.type == 'transfer'
? '${account?.name ?? transaction.accountId} → ${target?.name ?? transaction.targetAccountId}'
: account?.name ?? 'Compte ${transaction.accountId}';
return Card(
child: ListTile(
leading: Icon(transaction.isPending ? Icons.schedule_send : Icons.receipt_long),
title: Text(transaction.label),
subtitle: Text(
'${transaction.isPending ? 'EN ATTENTE • ' : ''}$typeLabel • $accountLabel${category == null ? '' : ' • ${category.name}'}',
),
trailing: Text(
money.format(transaction.amount),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
);
}
}
@@ -0,0 +1,223 @@
import 'account.dart';
class ReconciliationStatus {
final int accountId;
final String accountName;
final double appBalance;
final bool checkedToday;
final double? realBalance;
final double? difference;
ReconciliationStatus({
required this.accountId,
required this.accountName,
required this.appBalance,
required this.checkedToday,
this.realBalance,
this.difference,
});
factory ReconciliationStatus.fromJson(Map<String, dynamic> json) {
return ReconciliationStatus(
accountId: json['account_id'] as int,
accountName: json['account_name'] as String,
appBalance: (json['app_balance'] as num? ?? 0).toDouble(),
checkedToday: json['checked_today'] is int
? (json['checked_today'] as int) == 1
: json['checked_today'] as bool? ?? false,
realBalance: (json['real_balance'] as num?)?.toDouble(),
difference: (json['difference'] as num?)?.toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'account_id': accountId,
'account_name': accountName,
'app_balance': appBalance,
'checked_today': checkedToday,
'real_balance': realBalance,
'difference': difference,
};
}
ReconciliationStatus copyWith({
double? appBalance,
bool? checkedToday,
double? realBalance,
double? difference,
}) {
return ReconciliationStatus(
accountId: accountId,
accountName: accountName,
appBalance: appBalance ?? this.appBalance,
checkedToday: checkedToday ?? this.checkedToday,
realBalance: realBalance ?? this.realBalance,
difference: difference ?? this.difference,
);
}
}
class DashboardData {
final String month;
final double totalBalance;
final double availableAfterReserves;
final double monthlyIncome;
final double monthlyExpenses;
final double monthlyBalance;
final double provisionsCurrentTotal;
final double provisionsTargetTotal;
final double monthlyChargesRemainingTotal;
final double monthlyTransferRemainingTotal;
final double monthlyProvisionOperationRemainingTotal;
// Nouveaux champs backend : échéancier / opérations prévues ponctuelles.
final double scheduledExpensesRemainingTotal;
final double scheduledIncomeRemainingTotal;
final double scheduledTransferRemainingTotal;
final double scheduledProvisionRemainingTotal;
final double scheduledNetRemainingTotal;
final double projectedMonthBalance;
final List<Account> accounts;
final List<ReconciliationStatus> reconciliationsToday;
DashboardData({
required this.month,
required this.totalBalance,
required this.availableAfterReserves,
required this.monthlyIncome,
required this.monthlyExpenses,
required this.monthlyBalance,
required this.provisionsCurrentTotal,
required this.provisionsTargetTotal,
required this.monthlyChargesRemainingTotal,
required this.monthlyTransferRemainingTotal,
required this.monthlyProvisionOperationRemainingTotal,
required this.scheduledExpensesRemainingTotal,
required this.scheduledIncomeRemainingTotal,
required this.scheduledTransferRemainingTotal,
required this.scheduledProvisionRemainingTotal,
required this.scheduledNetRemainingTotal,
required this.projectedMonthBalance,
required this.accounts,
required this.reconciliationsToday,
});
double get plannedExpensesTotal =>
monthlyChargesRemainingTotal + scheduledExpensesRemainingTotal;
double get plannedTransfersTotal =>
monthlyTransferRemainingTotal + scheduledTransferRemainingTotal;
double get plannedProvisionsTotal =>
monthlyProvisionOperationRemainingTotal + scheduledProvisionRemainingTotal;
bool get hasScheduledInfo =>
scheduledExpensesRemainingTotal != 0 ||
scheduledIncomeRemainingTotal != 0 ||
scheduledTransferRemainingTotal != 0 ||
scheduledProvisionRemainingTotal != 0;
factory DashboardData.fromJson(Map<String, dynamic> json) {
final availableRaw = json['available_after_reserves'] ?? json['total_balance'] ?? 0;
final monthlyBalance = (json['monthly_balance'] as num? ?? 0).toDouble();
final scheduledIncome = (json['scheduled_income_remaining_total'] as num? ?? 0).toDouble();
final scheduledExpenses = (json['scheduled_expenses_remaining_total'] as num? ?? 0).toDouble();
return DashboardData(
month: json['month'] as String? ?? '',
totalBalance: (json['total_balance'] as num? ?? 0).toDouble(),
availableAfterReserves: (availableRaw as num).toDouble(),
monthlyIncome: (json['monthly_income'] as num? ?? 0).toDouble(),
monthlyExpenses: (json['monthly_expenses'] as num? ?? 0).toDouble(),
monthlyBalance: monthlyBalance,
provisionsCurrentTotal: (json['provisions_current_total'] as num? ?? 0).toDouble(),
provisionsTargetTotal: (json['provisions_target_total'] as num? ?? 0).toDouble(),
monthlyChargesRemainingTotal: (json['monthly_charges_remaining_total'] as num? ?? 0).toDouble(),
monthlyTransferRemainingTotal: (json['monthly_transfer_remaining_total'] as num? ?? 0).toDouble(),
monthlyProvisionOperationRemainingTotal: (json['monthly_provision_operation_remaining_total'] as num? ?? 0).toDouble(),
scheduledExpensesRemainingTotal: scheduledExpenses,
scheduledIncomeRemainingTotal: scheduledIncome,
scheduledTransferRemainingTotal: (json['scheduled_transfer_remaining_total'] as num? ?? 0).toDouble(),
scheduledProvisionRemainingTotal: (json['scheduled_provision_remaining_total'] as num? ?? 0).toDouble(),
scheduledNetRemainingTotal: (json['scheduled_net_remaining_total'] as num? ?? (scheduledIncome - scheduledExpenses)).toDouble(),
projectedMonthBalance: (json['projected_month_balance'] as num? ?? (monthlyBalance + scheduledIncome - scheduledExpenses)).toDouble(),
accounts: (json['accounts'] as List<dynamic>? ?? [])
.map((e) => Account.fromJson(e as Map<String, dynamic>))
.toList(),
reconciliationsToday: (json['reconciliations_today'] as List<dynamic>? ?? [])
.map((e) => ReconciliationStatus.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'month': month,
'total_balance': totalBalance,
'available_after_reserves': availableAfterReserves,
'monthly_income': monthlyIncome,
'monthly_expenses': monthlyExpenses,
'monthly_balance': monthlyBalance,
'provisions_current_total': provisionsCurrentTotal,
'provisions_target_total': provisionsTargetTotal,
'monthly_charges_remaining_total': monthlyChargesRemainingTotal,
'monthly_transfer_remaining_total': monthlyTransferRemainingTotal,
'monthly_provision_operation_remaining_total': monthlyProvisionOperationRemainingTotal,
'scheduled_expenses_remaining_total': scheduledExpensesRemainingTotal,
'scheduled_income_remaining_total': scheduledIncomeRemainingTotal,
'scheduled_transfer_remaining_total': scheduledTransferRemainingTotal,
'scheduled_provision_remaining_total': scheduledProvisionRemainingTotal,
'scheduled_net_remaining_total': scheduledNetRemainingTotal,
'projected_month_balance': projectedMonthBalance,
'accounts': accounts.map((e) => e.toJson()).toList(),
'reconciliations_today': reconciliationsToday.map((e) => e.toJson()).toList(),
};
}
DashboardData copyWith({
String? month,
double? totalBalance,
double? availableAfterReserves,
double? monthlyIncome,
double? monthlyExpenses,
double? monthlyBalance,
double? provisionsCurrentTotal,
double? provisionsTargetTotal,
double? monthlyChargesRemainingTotal,
double? monthlyTransferRemainingTotal,
double? monthlyProvisionOperationRemainingTotal,
double? scheduledExpensesRemainingTotal,
double? scheduledIncomeRemainingTotal,
double? scheduledTransferRemainingTotal,
double? scheduledProvisionRemainingTotal,
double? scheduledNetRemainingTotal,
double? projectedMonthBalance,
List<Account>? accounts,
List<ReconciliationStatus>? reconciliationsToday,
}) {
return DashboardData(
month: month ?? this.month,
totalBalance: totalBalance ?? this.totalBalance,
availableAfterReserves: availableAfterReserves ?? this.availableAfterReserves,
monthlyIncome: monthlyIncome ?? this.monthlyIncome,
monthlyExpenses: monthlyExpenses ?? this.monthlyExpenses,
monthlyBalance: monthlyBalance ?? this.monthlyBalance,
provisionsCurrentTotal: provisionsCurrentTotal ?? this.provisionsCurrentTotal,
provisionsTargetTotal: provisionsTargetTotal ?? this.provisionsTargetTotal,
monthlyChargesRemainingTotal: monthlyChargesRemainingTotal ?? this.monthlyChargesRemainingTotal,
monthlyTransferRemainingTotal: monthlyTransferRemainingTotal ?? this.monthlyTransferRemainingTotal,
monthlyProvisionOperationRemainingTotal: monthlyProvisionOperationRemainingTotal ?? this.monthlyProvisionOperationRemainingTotal,
scheduledExpensesRemainingTotal: scheduledExpensesRemainingTotal ?? this.scheduledExpensesRemainingTotal,
scheduledIncomeRemainingTotal: scheduledIncomeRemainingTotal ?? this.scheduledIncomeRemainingTotal,
scheduledTransferRemainingTotal: scheduledTransferRemainingTotal ?? this.scheduledTransferRemainingTotal,
scheduledProvisionRemainingTotal: scheduledProvisionRemainingTotal ?? this.scheduledProvisionRemainingTotal,
scheduledNetRemainingTotal: scheduledNetRemainingTotal ?? this.scheduledNetRemainingTotal,
projectedMonthBalance: projectedMonthBalance ?? this.projectedMonthBalance,
accounts: accounts ?? this.accounts,
reconciliationsToday: reconciliationsToday ?? this.reconciliationsToday,
);
}
}
@@ -0,0 +1,603 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/account.dart';
import '../models/category.dart';
import '../models/dashboard.dart';
import '../models/transaction_item.dart';
import '../services/sync_service.dart';
import 'quick_transaction_screen.dart';
import 'settings_screen.dart';
import 'sync_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key, required this.syncService});
final SyncService syncService;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
DashboardData? _dashboard;
List<Account> _accounts = [];
List<Category> _categories = [];
List<TransactionItem> _transactions = [];
int _pendingCount = 0;
DateTime? _lastSyncAt;
bool _isOnline = false;
bool _loading = true;
bool _syncing = false;
String? _message;
final _money = NumberFormat.currency(locale: 'fr_FR', symbol: 'MAD ', decimalDigits: 2);
final _date = DateFormat('dd/MM/yyyy HH:mm', 'fr_FR');
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
try {
final local = await widget.syncService.loadLocalSnapshot(
isOnline: false,
message: 'Données locales chargées.',
);
_applySnapshot(local, loading: false);
await _synchronize();
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_message = 'Erreur locale : $e';
});
}
}
void _applySnapshot(SyncSnapshot snapshot, {bool? loading}) {
if (!mounted) return;
setState(() {
_dashboard = snapshot.dashboard;
_accounts = snapshot.accounts;
_categories = snapshot.categories;
_transactions = snapshot.transactions;
_pendingCount = snapshot.pendingCount;
_lastSyncAt = snapshot.lastSyncAt;
_isOnline = snapshot.isOnline;
_message = snapshot.message;
if (loading != null) _loading = loading;
});
}
Future<void> _synchronize() async {
if (_syncing) return;
setState(() {
_syncing = true;
_message = 'Synchronisation...';
});
try {
final snapshot = await widget.syncService.synchronize();
if (!mounted) return;
_applySnapshot(snapshot, loading: false);
} catch (e) {
if (!mounted) return;
setState(() {
_message = 'Erreur de synchronisation : $e';
_loading = false;
});
} finally {
if (mounted) {
setState(() => _syncing = false);
}
}
}
Future<void> _reloadLocalAfterSave() async {
try {
final snapshot = await widget.syncService.loadLocalSnapshot(
isOnline: _isOnline,
message: _isOnline
? 'Opération enregistrée.'
: 'Opération en attente de synchronisation.',
);
_applySnapshot(snapshot, loading: false);
} catch (e) {
if (!mounted) return;
setState(() {
_message = 'Opération enregistrée localement, mais rechargement écran échoué : $e';
_loading = false;
});
}
}
Future<void> _openQuickAdd() async {
if (_accounts.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Crée d’abord au moins un compte dans la version web, puis synchronise.')),
);
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => QuickTransactionScreen(
syncService: widget.syncService,
accounts: _accounts,
categories: _categories,
onSaved: _reloadLocalAfterSave,
),
),
);
}
Future<void> _openSyncScreen() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SyncScreen(syncService: widget.syncService),
),
);
final snapshot = await widget.syncService.loadLocalSnapshot(
isOnline: _isOnline,
message: null,
);
_applySnapshot(snapshot, loading: false);
}
Future<void> _openSettings() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SettingsScreen(apiService: widget.syncService.apiService),
),
);
await _synchronize();
}
@override
Widget build(BuildContext context) {
final dashboard = _dashboard;
return Scaffold(
appBar: AppBar(
title: const Text('FIN65'),
actions: [
IconButton(
tooltip: 'Page synchronisation',
onPressed: _openSyncScreen,
icon: Badge(
isLabelVisible: _pendingCount > 0,
label: Text('$_pendingCount'),
child: const Icon(Icons.cloud_sync),
),
),
IconButton(
tooltip: 'Synchroniser',
onPressed: _syncing ? null : _synchronize,
icon: _syncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
),
IconButton(onPressed: _openSettings, icon: const Icon(Icons.settings)),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _openQuickAdd,
icon: const Icon(Icons.add),
label: const Text('Ajouter'),
),
body: RefreshIndicator(
onRefresh: _synchronize,
child: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
_SyncBanner(
isOnline: _isOnline,
pendingCount: _pendingCount,
lastSyncAt: _lastSyncAt,
formatter: _date,
message: _message,
),
const SizedBox(height: 12),
if (dashboard != null) ...[
_DashboardCards(
dashboard: dashboard,
money: _money,
hasPending: _pendingCount > 0,
),
const SizedBox(height: 16),
] else
const _EmptyCacheCard(),
_SectionTitle(title: 'Comptes', action: _pendingCount > 0 ? 'Solde estimé' : 'Solde app'),
const SizedBox(height: 8),
if (_accounts.isEmpty)
const Card(child: ListTile(title: Text('Aucun compte en cache. Synchronise avec le backend.')))
else
..._accounts.map((a) => _AccountTile(account: a, money: _money)),
const SizedBox(height: 16),
_SectionTitle(title: 'Dernières transactions'),
const SizedBox(height: 8),
if (_transactions.isEmpty)
const Card(child: ListTile(title: Text('Aucune transaction en cache.')))
else
..._transactions.map(
(t) => _TransactionTile(
transaction: t,
accounts: _accounts,
categories: _categories,
money: _money,
),
),
const SizedBox(height: 90),
],
),
),
);
}
}
class _SyncBanner extends StatelessWidget {
const _SyncBanner({
required this.isOnline,
required this.pendingCount,
required this.lastSyncAt,
required this.formatter,
this.message,
});
final bool isOnline;
final int pendingCount;
final DateTime? lastSyncAt;
final DateFormat formatter;
final String? message;
@override
Widget build(BuildContext context) {
final title = isOnline ? 'Connecté au backend' : 'Mode hors ligne';
final subtitleParts = <String>[];
if (lastSyncAt != null) {
subtitleParts.add('Dernière synchro : ${formatter.format(lastSyncAt!.toLocal())}');
} else {
subtitleParts.add('Aucune synchronisation réussie');
}
if (pendingCount > 0) {
subtitleParts.add('$pendingCount opération(s) en attente');
}
if (message != null && message!.isNotEmpty) {
subtitleParts.add(message!);
}
return Card(
child: ListTile(
leading: Icon(isOnline ? Icons.cloud_done : Icons.cloud_off),
title: Text(title),
subtitle: Text(subtitleParts.join('\n')),
),
);
}
}
class _EmptyCacheCard extends StatelessWidget {
const _EmptyCacheCard();
@override
Widget build(BuildContext context) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Aucune donnée locale. Lance le backend PC une première fois, configure l’URL API, puis synchronise.',
),
),
);
}
}
class _DashboardCards extends StatelessWidget {
const _DashboardCards({
required this.dashboard,
required this.money,
required this.hasPending,
});
final DashboardData dashboard;
final NumberFormat money;
final bool hasPending;
@override
Widget build(BuildContext context) {
final labelSuffix = hasPending ? ' estimé' : '';
return Column(
children: [
_MetricCard(
title: 'Disponible réel$labelSuffix',
value: money.format(dashboard.availableAfterReserves),
icon: Icons.account_balance_wallet,
),
const SizedBox(height: 8),
_MetricCard(
title: 'Balance prévisionnelle du mois',
value: money.format(dashboard.projectedMonthBalance),
icon: Icons.insights,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MetricCard(
title: 'Solde total$labelSuffix',
value: money.format(dashboard.totalBalance),
icon: Icons.savings,
compact: true,
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricCard(
title: 'Balance réelle',
value: money.format(dashboard.monthlyBalance),
icon: Icons.trending_up,
compact: true,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MetricCard(
title: 'Revenus réels',
value: money.format(dashboard.monthlyIncome),
icon: Icons.arrow_downward,
compact: true,
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricCard(
title: 'Dépenses réelles',
value: money.format(dashboard.monthlyExpenses),
icon: Icons.arrow_upward,
compact: true,
),
),
],
),
const SizedBox(height: 8),
_PlannedInfoCard(dashboard: dashboard, money: money),
],
);
}
}
class _PlannedInfoCard extends StatelessWidget {
const _PlannedInfoCard({
required this.dashboard,
required this.money,
});
final DashboardData dashboard;
final NumberFormat money;
@override
Widget build(BuildContext context) {
final rows = <_InfoRow>[
_InfoRow('Dépenses prévues restantes', dashboard.plannedExpensesTotal),
_InfoRow('Revenus prévus échéancier', dashboard.scheduledIncomeRemainingTotal),
_InfoRow('Dépenses échéancier', dashboard.scheduledExpensesRemainingTotal),
_InfoRow('Transferts prévus', dashboard.plannedTransfersTotal),
_InfoRow('Provisions prévues', dashboard.plannedProvisionsTotal),
];
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.event_note),
const SizedBox(width: 10),
Expanded(
child: Text(
'Prévisionnel du mois',
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 8),
Text(
'Inclut les opérations mensuelles et l’échéancier ponctuel du backend.',
style: Theme.of(context).textTheme.bodySmall,
),
const Divider(height: 20),
...rows.map(
(row) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(child: Text(row.label)),
Text(
money.format(row.value),
style: const TextStyle(fontWeight: FontWeight.w700),
),
],
),
),
),
],
),
),
);
}
}
class _InfoRow {
const _InfoRow(this.label, this.value);
final String label;
final double value;
}
class _MetricCard extends StatelessWidget {
const _MetricCard({
required this.title,
required this.value,
required this.icon,
this.compact = false,
});
final String title;
final String value;
final IconData icon;
final bool compact;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: EdgeInsets.all(compact ? 12 : 16),
child: Row(
children: [
Icon(icon, size: compact ? 24 : 32),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodySmall),
Text(
value,
style: compact
? Theme.of(context).textTheme.titleMedium
: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
],
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title, this.action});
final String title;
final String? action;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
if (action != null) Text(action!, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}
class _AccountTile extends StatelessWidget {
const _AccountTile({required this.account, required this.money});
final Account account;
final NumberFormat money;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: const Icon(Icons.account_balance),
title: Text(account.name),
subtitle: Text(account.type),
trailing: Text(
money.format(account.appBalance),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
);
}
}
class _TransactionTile extends StatelessWidget {
const _TransactionTile({
required this.transaction,
required this.accounts,
required this.categories,
required this.money,
});
final TransactionItem transaction;
final List<Account> accounts;
final List<Category> categories;
final NumberFormat money;
Account? _findAccount(int? id) {
if (id == null) return null;
for (final account in accounts) {
if (account.id == id) return account;
}
return null;
}
Category? _findCategory(int? id) {
if (id == null) return null;
for (final category in categories) {
if (category.id == id) return category;
}
return null;
}
@override
Widget build(BuildContext context) {
final account = _findAccount(transaction.accountId);
final target = _findAccount(transaction.targetAccountId);
final category = _findCategory(transaction.categoryId);
final typeLabel = switch (transaction.type) {
'expense' => 'Dépense',
'income' => 'Recette',
'transfer' => 'Transfert',
_ => transaction.type,
};
final accountLabel = transaction.type == 'transfer'
? '${account?.name ?? transaction.accountId} → ${target?.name ?? transaction.targetAccountId}'
: account?.name ?? 'Compte ${transaction.accountId}';
return Card(
child: ListTile(
leading: Icon(transaction.isPending ? Icons.schedule_send : Icons.receipt_long),
title: Text(transaction.label),
subtitle: Text(
'${transaction.isPending ? 'EN ATTENTE • ' : ''}$typeLabel • $accountLabel${category == null ? '' : ' • ${category.name}'}',
),
trailing: Text(
money.format(transaction.amount),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
);
}
}
@@ -0,0 +1,36 @@
# FIN65 - Réparation Gradle biométrie
Ce patch corrige l'erreur :
```text
build.gradle.kts
kotlin {
compilerOptions {
jvmTarget = ...
}
}
Unresolved reference 'compilerOptions'
```
L'ancien patch Android biométrie a ajouté un bloc Kotlin DSL invalide dans `android/app/build.gradle.kts`.
## Utilisation
Décompresse ce ZIP puis lance :
```powershell
.\repair_gradle_biometrics.bat
```
Ensuite :
```powershell
cd D:\07-Logiciels\20-FIN65\fin65_mobile
flutter run
```
Le script :
- sauvegarde `build.gradle.kts` ;
- supprime le bloc `kotlin { compilerOptions { ... } }` invalide ;
- garde `minSdk = 24` ;
- lance `flutter clean` et `flutter pub get`.
@@ -0,0 +1,4 @@
@echo off
setlocal
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0repair_gradle_biometrics.bat.ps1"
@@ -0,0 +1,89 @@
$MobileDir = "D:\07-Logiciels\20-FIN65\fin65_mobile"
$GradleKts = Join-Path $MobileDir "android\app\build.gradle.kts"
$GradleGroovy = Join-Path $MobileDir "android\app\build.gradle"
Write-Host ""
Write-Host "=========================================="
Write-Host " FIN65 - Repair Gradle biometrie"
Write-Host "=========================================="
Write-Host ""
function Backup-File($path) {
if (Test-Path $path) {
$Stamp = Get-Date -Format "yyyyMMdd_HHmmss"
Copy-Item $path "$path.before_gradle_repair_$Stamp.bak" -Force
Write-Host "Sauvegarde : $path.before_gradle_repair_$Stamp.bak"
}
}
if (!(Test-Path $MobileDir)) {
Write-Host "[ERREUR] Dossier mobile introuvable : $MobileDir" -ForegroundColor Red
Read-Host "Appuie sur Entree pour fermer"
exit 1
}
if (Test-Path $GradleKts) {
Backup-File $GradleKts
$text = Get-Content $GradleKts -Raw
# Supprime le bloc Kotlin DSL invalide ajouté par l'ancien patch :
# kotlin {
# compilerOptions {
# jvmTarget = ...
# }
# }
$text = [regex]::Replace(
$text,
'(?ms)^\s*kotlin\s*\{\s*compilerOptions\s*\{.*?\}\s*\}\s*$',
''
)
# Forcer minSdk a 24 sans toucher au reste.
$text = [regex]::Replace($text, 'minSdk\s*=\s*flutter\.minSdkVersion', 'minSdk = 24')
$text = [regex]::Replace($text, 'minSdk\s*=\s*\d+', 'minSdk = 24')
$text = [regex]::Replace($text, 'minSdkVersion\s+flutter\.minSdkVersion', 'minSdkVersion 24')
$text = [regex]::Replace($text, 'minSdkVersion\s+\d+', 'minSdkVersion 24')
Set-Content -Path $GradleKts -Value $text -Encoding UTF8
Write-Host "build.gradle.kts repare."
} elseif (Test-Path $GradleGroovy) {
Backup-File $GradleGroovy
$text = Get-Content $GradleGroovy -Raw
$text = [regex]::Replace(
$text,
'(?ms)^\s*kotlin\s*\{\s*compilerOptions\s*\{.*?\}\s*\}\s*$',
''
)
$text = [regex]::Replace($text, 'minSdkVersion\s+flutter\.minSdkVersion', 'minSdkVersion 24')
$text = [regex]::Replace($text, 'minSdkVersion\s+\d+', 'minSdkVersion 24')
Set-Content -Path $GradleGroovy -Value $text -Encoding UTF8
Write-Host "build.gradle repare."
} else {
Write-Host "[ERREUR] Aucun build.gradle(.kts) trouve." -ForegroundColor Red
Read-Host "Appuie sur Entree pour fermer"
exit 1
}
Set-Location $MobileDir
Write-Host ""
Write-Host "Verification rapide :"
if (Test-Path $GradleKts) {
Select-String -Path $GradleKts -Pattern "kotlin \{|compilerOptions|jvmTarget|minSdk" | ForEach-Object { Write-Host $_.Line }
} else {
Select-String -Path $GradleGroovy -Pattern "kotlin \{|compilerOptions|jvmTarget|minSdk" | ForEach-Object { Write-Host $_.Line }
}
Write-Host ""
Write-Host "Nettoyage Flutter..."
flutter clean
flutter pub get
Write-Host ""
Write-Host "Reparation Gradle terminee."
Write-Host "Relance maintenant :"
Write-Host "flutter run"
Write-Host ""
Read-Host "Appuie sur Entree pour fermer"
@@ -0,0 +1,33 @@
# FIN65 - Patch icône Android direct V2
Cette version corrige l'erreur PowerShell :
```text
The regular expression pattern android:label="[^]* is not valid
```
## Utilisation
Décompresse le ZIP puis lance :
```powershell
.\apply_fin65_android_icon_direct_v2.bat
```
Ensuite relance :
```powershell
cd D:\07-Logiciels\20-FIN65\fin65_mobile
flutter run
```
## Si l’icône ne change pas
Android garde parfois l’ancienne icône en cache.
Dans ce cas :
1. synchronise toutes les opérations en attente ;
2. désinstalle l'app du téléphone ;
3. relance `flutter run`.
Attention : désinstaller l’app supprime la base SQLite locale mobile.
@@ -0,0 +1,86 @@
@echo off
setlocal enabledelayedexpansion
REM ============================================================
REM FIN65 - Patch icone Android direct V2
REM Corrige le probleme de regex PowerShell dans le .bat precedent.
REM ============================================================
set "ROOT_DIR=D:\07-Logiciels\20-FIN65"
set "MOBILE_DIR=%ROOT_DIR%\fin65_mobile"
set "PUB_CACHE=%ROOT_DIR%\.pub-cache"
set "RES_DIR=%MOBILE_DIR%\android\app\src\main\res"
set "MANIFEST=%MOBILE_DIR%\android\app\src\main\AndroidManifest.xml"
echo.
echo ==========================================
echo FIN65 - Patch icone Android direct V2
echo ==========================================
echo.
if not exist "%MOBILE_DIR%" (
echo [ERREUR] Dossier mobile introuvable :
echo %MOBILE_DIR%
pause
exit /b 1
)
if not exist "%RES_DIR%" (
echo [ERREUR] Dossier res Android introuvable :
echo %RES_DIR%
pause
exit /b 1
)
if not exist "%MANIFEST%" (
echo [ERREUR] AndroidManifest.xml introuvable :
echo %MANIFEST%
pause
exit /b 1
)
cd /d "%MOBILE_DIR%"
echo Copie des icones natives Android...
for %%D in (mipmap-mdpi mipmap-hdpi mipmap-xhdpi mipmap-xxhdpi mipmap-xxxhdpi mipmap-anydpi-v26 values) do (
if not exist "%RES_DIR%\%%D" mkdir "%RES_DIR%\%%D"
copy /Y "%~dp0generated_res\%%D\*" "%RES_DIR%\%%D\" >nul
)
echo.
echo Mise a jour du label et de l'icone dans AndroidManifest.xml...
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0patch_manifest_icon.ps1" -ManifestPath "%MANIFEST%"
if errorlevel 1 (
echo.
echo [ERREUR] Patch du Manifest echoue.
pause
exit /b 1
)
echo.
echo Verification rapide :
findstr /C:"android:label" "%MANIFEST%"
findstr /C:"android:icon" "%MANIFEST%"
findstr /C:"android:roundIcon" "%MANIFEST%"
echo.
echo Nettoyage Flutter...
flutter clean
flutter pub get
echo.
echo ==========================================
echo Icone native FIN65 appliquee.
echo ==========================================
echo.
echo Relance :
echo flutter run
echo.
echo Si l'ancienne icone reste visible, Android garde le cache :
echo 1. Synchronise les operations en attente.
echo 2. Desinstalle l'app du telephone.
echo 3. Relance flutter run.
echo.
pause
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

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

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0F172A</color>
</resources>
@@ -0,0 +1,30 @@
param(
[Parameter(Mandatory=$true)]
[string]$ManifestPath
)
if (!(Test-Path $ManifestPath)) {
Write-Host "[ERREUR] AndroidManifest.xml introuvable : $ManifestPath" -ForegroundColor Red
exit 1
}
$text = Get-Content $ManifestPath -Raw
# Remplace les attributs existants.
$text = [regex]::Replace($text, 'android:label="[^"]*"', 'android:label="FIN65"')
$text = [regex]::Replace($text, 'android:icon="[^"]*"', 'android:icon="@mipmap/ic_launcher"')
$text = [regex]::Replace($text, 'android:roundIcon="[^"]*"', 'android:roundIcon="@mipmap/ic_launcher_round"')
# Ajoute les attributs s'ils n'existaient pas.
if ($text -notmatch 'android:label=') {
$text = $text -replace '<application', '<application android:label="FIN65"'
}
if ($text -notmatch 'android:icon=') {
$text = $text -replace '<application', '<application android:icon="@mipmap/ic_launcher"'
}
if ($text -notmatch 'android:roundIcon=') {
$text = $text -replace '<application', '<application android:roundIcon="@mipmap/ic_launcher_round"'
}
Set-Content -Path $ManifestPath -Value $text -Encoding UTF8
Write-Host "AndroidManifest.xml mis a jour."
@@ -0,0 +1,32 @@
# FIN65 - Fix BOM MainActivity.java
Ce patch corrige l'erreur Java :
```text
illegal character: '\ufeff'
```
Le fichier `MainActivity.java` a été écrit en UTF-8 avec BOM. Java n'aime pas ce caractère invisible au début du fichier. Parce qu'évidemment, même un caractère qu'on ne voit pas peut ruiner une compilation.
## Utilisation
Décompresse ce ZIP puis lance :
```powershell
.\fix_mainactivity_bom.bat
```
Ensuite :
```powershell
cd D:\07-Logiciels\20-FIN65\fin65_mobile
flutter run
```
Le script réécrit simplement :
```text
android/app/src/main/java/com/example/fin65_mobile/MainActivity.java
```
en UTF-8 sans BOM.
@@ -0,0 +1,4 @@
@echo off
setlocal
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0fix_mainactivity_bom.bat.ps1"
@@ -0,0 +1,53 @@
$MobileDir = "D:\07-Logiciels\20-FIN65\fin65_mobile"
$MainActivity = Join-Path $MobileDir "android\app\src\main\java\com\example\fin65_mobile\MainActivity.java"
Write-Host ""
Write-Host "=========================================="
Write-Host " FIN65 - Fix BOM MainActivity.java"
Write-Host "=========================================="
Write-Host ""
if (!(Test-Path $MainActivity)) {
Write-Host "[ERREUR] MainActivity.java introuvable :" -ForegroundColor Red
Write-Host $MainActivity
Read-Host "Appuie sur Entree pour fermer"
exit 1
}
$Stamp = Get-Date -Format "yyyyMMdd_HHmmss"
Copy-Item $MainActivity "$MainActivity.before_bom_fix_$Stamp.bak" -Force
Write-Host "Sauvegarde : $MainActivity.before_bom_fix_$Stamp.bak"
# Réécriture explicite sans BOM.
$content = @"
package com.example.fin65_mobile;
import io.flutter.embedding.android.FlutterFragmentActivity;
public class MainActivity extends FlutterFragmentActivity {
}
"@
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($MainActivity, $content, $Utf8NoBom)
Write-Host ""
Write-Host "MainActivity.java reecrit en UTF-8 sans BOM."
Write-Host ""
Write-Host "Contenu actuel :"
Get-Content $MainActivity | ForEach-Object { Write-Host $_ }
Set-Location $MobileDir
Write-Host ""
Write-Host "Nettoyage Flutter..."
flutter clean
flutter pub get
Write-Host ""
Write-Host "Fix BOM termine."
Write-Host "Relance maintenant :"
Write-Host "flutter run"
Write-Host ""
Read-Host "Appuie sur Entree pour fermer"
@@ -0,0 +1,50 @@
# FIN65 - Réparation MainActivity Java
Ce patch corrige le crash :
```text
ClassNotFoundException:
Didn't find class "com.example.fin65_mobile.MainActivity"
```
Le patch précédent créait une `MainActivity.kt`, mais elle n'est visiblement pas embarquée dans l'APK. Donc cette version crée une `MainActivity.java`, plus basique et plus sûre côté compilation Android.
## Ce que ça fait
- désactive les anciennes `MainActivity.kt` / `MainActivity.java` ;
- crée :
```text
android/app/src/main/java/com/example/fin65_mobile/MainActivity.java
```
avec :
```java
public class MainActivity extends FlutterFragmentActivity {}
```
- force `AndroidManifest.xml` à pointer vers :
```text
com.example.fin65_mobile.MainActivity
```
- garde `USE_BIOMETRIC`, `INTERNET`, `usesCleartextTraffic=true`, label FIN65 et icône FIN65 ;
- nettoie Gradle si besoin ;
- lance `flutter clean` et `flutter pub get`.
## Utilisation
Décompresse, puis lance :
```powershell
.\repair_mainactivity_java.bat
```
Ensuite :
```powershell
cd D:\07-Logiciels\20-FIN65\fin65_mobile
flutter run
```
@@ -0,0 +1,4 @@
@echo off
setlocal
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0repair_mainactivity_java.bat.ps1"
@@ -0,0 +1,267 @@
$MobileDir = "D:\07-Logiciels\20-FIN65\fin65_mobile"
$AndroidMain = Join-Path $MobileDir "android\app\src\main"
$Manifest = Join-Path $AndroidMain "AndroidManifest.xml"
$GradleKts = Join-Path $MobileDir "android\app\build.gradle.kts"
$GradleGroovy = Join-Path $MobileDir "android\app\build.gradle"
Write-Host ""
Write-Host "=========================================="
Write-Host " FIN65 - Repair MainActivity Java"
Write-Host "=========================================="
Write-Host ""
if (!(Test-Path $MobileDir)) {
Write-Host "[ERREUR] Dossier mobile introuvable : $MobileDir" -ForegroundColor Red
Read-Host "Appuie sur Entree pour fermer"
exit 1
}
if (!(Test-Path $Manifest)) {
Write-Host "[ERREUR] AndroidManifest.xml introuvable : $Manifest" -ForegroundColor Red
Read-Host "Appuie sur Entree pour fermer"
exit 1
}
$Stamp = Get-Date -Format "yyyyMMdd_HHmmss"
function Backup-File($Path) {
if (Test-Path $Path) {
Copy-Item $Path "$Path.before_java_mainactivity_$Stamp.bak" -Force
Write-Host "Sauvegarde : $Path.before_java_mainactivity_$Stamp.bak"
}
}
Backup-File $Manifest
if (Test-Path $GradleKts) { Backup-File $GradleKts }
if (Test-Path $GradleGroovy) { Backup-File $GradleGroovy }
# 1) Package/applicationId exact.
$PackageName = "com.example.fin65_mobile"
if (Test-Path $GradleKts) {
$g = Get-Content $GradleKts -Raw
$mApp = [regex]::Match($g, 'applicationId\s*=\s*"([^"]+)"')
$mNs = [regex]::Match($g, 'namespace\s*=\s*"([^"]+)"')
if ($mNs.Success) { $PackageName = $mNs.Groups[1].Value }
if ($mApp.Success) { $PackageName = $mApp.Groups[1].Value }
} elseif (Test-Path $GradleGroovy) {
$g = Get-Content $GradleGroovy -Raw
$mApp = [regex]::Match($g, 'applicationId\s+["'']([^"'']+)["'']')
$mNs = [regex]::Match($g, 'namespace\s+["'']([^"'']+)["'']')
if ($mNs.Success) { $PackageName = $mNs.Groups[1].Value }
if ($mApp.Success) { $PackageName = $mApp.Groups[1].Value }
}
Write-Host "Package Android retenu : $PackageName"
# 2) Désactiver toutes les anciennes MainActivity pour éviter les doublons.
Write-Host ""
Write-Host "Desactivation des anciennes MainActivity..."
$oldMainActivities = @()
$kotlinRoot = Join-Path $AndroidMain "kotlin"
$javaRoot = Join-Path $AndroidMain "java"
if (Test-Path $kotlinRoot) {
$oldMainActivities += Get-ChildItem $kotlinRoot -Recurse -Filter MainActivity.kt -ErrorAction SilentlyContinue
}
if (Test-Path $javaRoot) {
$oldMainActivities += Get-ChildItem $javaRoot -Recurse -Filter MainActivity.java -ErrorAction SilentlyContinue
}
foreach ($file in $oldMainActivities) {
$disabled = "$($file.FullName).disabled_$Stamp"
Move-Item $file.FullName $disabled -Force
Write-Host "Desactive : $($file.FullName)"
}
# 3) Créer une MainActivity Java. Java est compilé par Android sans dépendre du plugin Kotlin.
$PackagePath = $PackageName.Replace(".", "\")
$JavaDir = Join-Path $AndroidMain "java\$PackagePath"
$MainActivityJava = Join-Path $JavaDir "MainActivity.java"
if (!(Test-Path $JavaDir)) {
New-Item -ItemType Directory -Force -Path $JavaDir | Out-Null
}
$MainActivityText = @"
package $PackageName;
import io.flutter.embedding.android.FlutterFragmentActivity;
public class MainActivity extends FlutterFragmentActivity {
}
"@
Set-Content -Path $MainActivityJava -Value $MainActivityText -Encoding UTF8
Write-Host ""
Write-Host "MainActivity Java creee :"
Write-Host $MainActivityJava
# 4) Patch AndroidManifest propre via XML.
[xml]$xml = Get-Content $Manifest
$androidNs = "http://schemas.android.com/apk/res/android"
$manifestNode = $xml.manifest
function Ensure-Permission($name) {
$existing = $manifestNode.'uses-permission' | Where-Object {
$_.GetAttribute("name", $androidNs) -eq $name
}
if ($null -eq $existing) {
$perm = $xml.CreateElement("uses-permission")
$attr = $xml.CreateAttribute("android", "name", $androidNs)
$attr.Value = $name
[void]$perm.Attributes.Append($attr)
[void]$manifestNode.InsertBefore($perm, $manifestNode.FirstChild)
Write-Host "Permission ajoutee : $name"
} else {
Write-Host "Permission deja presente : $name"
}
}
Ensure-Permission "android.permission.INTERNET"
Ensure-Permission "android.permission.USE_BIOMETRIC"
$app = $manifestNode.application
if ($null -eq $app) {
Write-Host "[ERREUR] Balise <application> introuvable." -ForegroundColor Red
Read-Host "Appuie sur Entree pour fermer"
exit 1
}
$app.SetAttribute("label", $androidNs, "FIN65")
$app.SetAttribute("icon", $androidNs, "@mipmap/ic_launcher")
$app.SetAttribute("roundIcon", $androidNs, "@mipmap/ic_launcher_round")
$app.SetAttribute("usesCleartextTraffic", $androidNs, "true")
# Chercher l'activité launcher.
$activities = @($app.activity)
$launcherActivity = $null
foreach ($a in $activities) {
if ($null -eq $a) { continue }
$hasLauncher = $false
foreach ($f in @($a.'intent-filter')) {
if ($null -eq $f) { continue }
foreach ($cat in @($f.category)) {
if ($cat.GetAttribute("name", $androidNs) -eq "android.intent.category.LAUNCHER") {
$hasLauncher = $true
}
}
}
if ($hasLauncher) {
$launcherActivity = $a
break
}
}
if ($null -eq $launcherActivity) {
if ($activities.Count -gt 0 -and $null -ne $activities[0]) {
$launcherActivity = $activities[0]
} else {
$launcherActivity = $xml.CreateElement("activity")
[void]$app.PrependChild($launcherActivity)
}
}
$launcherActivity.SetAttribute("name", $androidNs, "$PackageName.MainActivity")
$launcherActivity.SetAttribute("exported", $androidNs, "true")
$launcherActivity.SetAttribute("launchMode", $androidNs, "singleTop")
$launcherActivity.SetAttribute("taskAffinity", $androidNs, "")
$launcherActivity.SetAttribute("theme", $androidNs, "@style/LaunchTheme")
$launcherActivity.SetAttribute("configChanges", $androidNs, "orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode")
$launcherActivity.SetAttribute("hardwareAccelerated", $androidNs, "true")
$launcherActivity.SetAttribute("windowSoftInputMode", $androidNs, "adjustResize")
# S'assurer du meta-data NormalTheme.
$hasNormalTheme = $false
foreach ($md in @($launcherActivity.'meta-data')) {
if ($null -ne $md -and $md.GetAttribute("name", $androidNs) -eq "io.flutter.embedding.android.NormalTheme") {
$hasNormalTheme = $true
}
}
if (!$hasNormalTheme) {
$meta = $xml.CreateElement("meta-data")
$meta.SetAttribute("name", $androidNs, "io.flutter.embedding.android.NormalTheme")
$meta.SetAttribute("resource", $androidNs, "@style/NormalTheme")
[void]$launcherActivity.PrependChild($meta)
}
# S'assurer du launcher intent-filter.
$hasLauncherFilter = $false
foreach ($f in @($launcherActivity.'intent-filter')) {
if ($null -eq $f) { continue }
foreach ($cat in @($f.category)) {
if ($cat.GetAttribute("name", $androidNs) -eq "android.intent.category.LAUNCHER") {
$hasLauncherFilter = $true
}
}
}
if (!$hasLauncherFilter) {
$filter = $xml.CreateElement("intent-filter")
$action = $xml.CreateElement("action")
$action.SetAttribute("name", $androidNs, "android.intent.action.MAIN")
[void]$filter.AppendChild($action)
$category = $xml.CreateElement("category")
$category.SetAttribute("name", $androidNs, "android.intent.category.LAUNCHER")
[void]$filter.AppendChild($category)
[void]$launcherActivity.AppendChild($filter)
}
# S'assurer du flutterEmbedding.
$hasFlutterEmbedding = $false
foreach ($md in @($app.'meta-data')) {
if ($null -ne $md -and $md.GetAttribute("name", $androidNs) -eq "flutterEmbedding") {
$hasFlutterEmbedding = $true
}
}
if (!$hasFlutterEmbedding) {
$md = $xml.CreateElement("meta-data")
$md.SetAttribute("name", $androidNs, "flutterEmbedding")
$md.SetAttribute("value", $androidNs, "2")
[void]$app.AppendChild($md)
}
$xml.Save($Manifest)
Write-Host ""
Write-Host "AndroidManifest.xml mis a jour."
# 5) Gradle : nettoyer bloc Kotlin erroné et garder minSdk 24.
if (Test-Path $GradleKts) {
$text = Get-Content $GradleKts -Raw
$text = [regex]::Replace($text, '(?ms)^\s*kotlin\s*\{\s*compilerOptions\s*\{.*?\}\s*\}\s*$', '')
$text = [regex]::Replace($text, 'minSdk\s*=\s*flutter\.minSdkVersion', 'minSdk = 24')
$text = [regex]::Replace($text, 'minSdk\s*=\s*\d+', 'minSdk = 24')
Set-Content -Path $GradleKts -Value $text -Encoding UTF8
Write-Host "build.gradle.kts verifie."
}
if (Test-Path $GradleGroovy) {
$text = Get-Content $GradleGroovy -Raw
$text = [regex]::Replace($text, '(?ms)^\s*kotlin\s*\{\s*compilerOptions\s*\{.*?\}\s*\}\s*$', '')
$text = [regex]::Replace($text, 'minSdkVersion\s+flutter\.minSdkVersion', 'minSdkVersion 24')
$text = [regex]::Replace($text, 'minSdkVersion\s+\d+', 'minSdkVersion 24')
Set-Content -Path $GradleGroovy -Value $text -Encoding UTF8
Write-Host "build.gradle verifie."
}
Write-Host ""
Write-Host "Verification des fichiers MainActivity restants :"
Get-ChildItem $AndroidMain -Recurse -Include MainActivity.kt,MainActivity.java -ErrorAction SilentlyContinue | ForEach-Object {
Write-Host " - $($_.FullName)"
}
Set-Location $MobileDir
Write-Host ""
Write-Host "Nettoyage Flutter..."
flutter clean
flutter pub get
Write-Host ""
Write-Host "Reparation MainActivity Java terminee."
Write-Host "Relance maintenant :"
Write-Host "flutter run"
Write-Host ""
Read-Host "Appuie sur Entree pour fermer"
@@ -0,0 +1,43 @@
# FIN65 - Réparation MainActivity biométrie
Ce patch corrige le crash Android :
```text
ClassNotFoundException:
Didn't find class "com.example.fin65_mobile.MainActivity"
```
La compilation réussit, mais Android ne trouve pas la classe lancée par le Manifest. Donc l'APK s'installe, puis l'app crashe au démarrage. Très théâtral, très Android.
## Ce que le script fait
- détecte le package Android depuis `build.gradle.kts` ;
- recrée `MainActivity.kt` au bon endroit ;
- utilise `FlutterFragmentActivity`, nécessaire pour la biométrie ;
- force le Manifest à pointer vers la classe complète :
`com.example.fin65_mobile.MainActivity`
- garde :
- `INTERNET`
- `USE_BIOMETRIC`
- `usesCleartextTraffic=true`
- icône FIN65
- label FIN65
- supprime si besoin le bloc Gradle Kotlin invalide revenu par accident ;
- lance `flutter clean` et `flutter pub get`.
## Utilisation
Décompresse puis lance :
```powershell
.\repair_mainactivity_biometrics.bat
```
Ensuite :
```powershell
cd D:\07-Logiciels\20-FIN65\fin65_mobile
flutter run
```
Ne relance pas les anciens patchs biométrie après celui-ci.
@@ -0,0 +1,4 @@
@echo off
setlocal
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0repair_mainactivity_biometrics.bat.ps1"
@@ -0,0 +1,245 @@
$MobileDir = "D:\07-Logiciels\20-FIN65\fin65_mobile"
$AndroidMain = Join-Path $MobileDir "android\app\src\main"
$Manifest = Join-Path $AndroidMain "AndroidManifest.xml"
$GradleKts = Join-Path $MobileDir "android\app\build.gradle.kts"
$GradleGroovy = Join-Path $MobileDir "android\app\build.gradle"
Write-Host ""
Write-Host "=========================================="
Write-Host " FIN65 - Repair MainActivity biometrie"
Write-Host "=========================================="
Write-Host ""
if (!(Test-Path $MobileDir)) {
Write-Host "[ERREUR] Dossier mobile introuvable : $MobileDir" -ForegroundColor Red
Read-Host "Appuie sur Entree pour fermer"
exit 1
}
if (!(Test-Path $Manifest)) {
Write-Host "[ERREUR] AndroidManifest.xml introuvable : $Manifest" -ForegroundColor Red
Read-Host "Appuie sur Entree pour fermer"
exit 1
}
$Stamp = Get-Date -Format "yyyyMMdd_HHmmss"
function Backup-File($Path) {
if (Test-Path $Path) {
Copy-Item $Path "$Path.before_mainactivity_repair_$Stamp.bak" -Force
Write-Host "Sauvegarde : $Path.before_mainactivity_repair_$Stamp.bak"
}
}
Backup-File $Manifest
if (Test-Path $GradleKts) { Backup-File $GradleKts }
if (Test-Path $GradleGroovy) { Backup-File $GradleGroovy }
# 1) Déterminer package/namespace Android.
$PackageName = "com.example.fin65_mobile"
if (Test-Path $GradleKts) {
$g = Get-Content $GradleKts -Raw
$m = [regex]::Match($g, 'namespace\s*=\s*"([^"]+)"')
if ($m.Success) { $PackageName = $m.Groups[1].Value }
$m2 = [regex]::Match($g, 'applicationId\s*=\s*"([^"]+)"')
if ($m2.Success) { $PackageName = $m2.Groups[1].Value }
} elseif (Test-Path $GradleGroovy) {
$g = Get-Content $GradleGroovy -Raw
$m = [regex]::Match($g, 'namespace\s+["'']([^"'']+)["'']')
if ($m.Success) { $PackageName = $m.Groups[1].Value }
$m2 = [regex]::Match($g, 'applicationId\s+["'']([^"'']+)["'']')
if ($m2.Success) { $PackageName = $m2.Groups[1].Value }
}
Write-Host "Package Android retenu : $PackageName"
# 2) Créer MainActivity.kt au bon endroit avec FlutterFragmentActivity.
$PackagePath = $PackageName.Replace(".", "\")
$KotlinDir = Join-Path $AndroidMain "kotlin\$PackagePath"
$MainActivity = Join-Path $KotlinDir "MainActivity.kt"
if (!(Test-Path $KotlinDir)) {
New-Item -ItemType Directory -Force -Path $KotlinDir | Out-Null
}
if (Test-Path $MainActivity) {
Backup-File $MainActivity
}
$MainActivityText = @"
package $PackageName
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity()
"@
Set-Content -Path $MainActivity -Value $MainActivityText -Encoding UTF8
Write-Host "MainActivity.kt recrée : $MainActivity"
# 3) Patch Manifest sans casser XML.
[xml]$xml = Get-Content $Manifest
$androidNs = "http://schemas.android.com/apk/res/android"
$manifestNode = $xml.manifest
function Ensure-Permission($name) {
$existing = $manifestNode.'uses-permission' | Where-Object {
$_.GetAttribute("name", $androidNs) -eq $name
}
if ($null -eq $existing) {
$perm = $xml.CreateElement("uses-permission")
$attr = $xml.CreateAttribute("android", "name", $androidNs)
$attr.Value = $name
[void]$perm.Attributes.Append($attr)
[void]$manifestNode.InsertBefore($perm, $manifestNode.FirstChild)
Write-Host "Permission ajoutee : $name"
} else {
Write-Host "Permission deja presente : $name"
}
}
Ensure-Permission "android.permission.INTERNET"
Ensure-Permission "android.permission.USE_BIOMETRIC"
$app = $manifestNode.application
if ($null -eq $app) {
Write-Host "[ERREUR] Balise <application> introuvable dans AndroidManifest.xml" -ForegroundColor Red
Read-Host "Appuie sur Entree pour fermer"
exit 1
}
$app.SetAttribute("label", $androidNs, "FIN65")
$app.SetAttribute("icon", $androidNs, "@mipmap/ic_launcher")
$app.SetAttribute("roundIcon", $androidNs, "@mipmap/ic_launcher_round")
$app.SetAttribute("usesCleartextTraffic", $androidNs, "true")
$activities = @($app.activity)
$mainActivityNode = $null
foreach ($a in $activities) {
if ($null -ne $a) {
$name = $a.GetAttribute("name", $androidNs)
$exported = $a.GetAttribute("exported", $androidNs)
$hasLauncher = $false
foreach ($f in @($a.'intent-filter')) {
if ($null -ne $f) {
foreach ($cat in @($f.category)) {
if ($cat.GetAttribute("name", $androidNs) -eq "android.intent.category.LAUNCHER") {
$hasLauncher = $true
}
}
}
}
if ($hasLauncher -or $name -like "*MainActivity*") {
$mainActivityNode = $a
break
}
}
}
if ($null -eq $mainActivityNode) {
$mainActivityNode = $xml.CreateElement("activity")
[void]$app.PrependChild($mainActivityNode)
}
$mainActivityNode.SetAttribute("name", $androidNs, "$PackageName.MainActivity")
$mainActivityNode.SetAttribute("exported", $androidNs, "true")
$mainActivityNode.SetAttribute("launchMode", $androidNs, "singleTop")
$mainActivityNode.SetAttribute("taskAffinity", $androidNs, "")
$mainActivityNode.SetAttribute("theme", $androidNs, "@style/LaunchTheme")
$mainActivityNode.SetAttribute("configChanges", $androidNs, "orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode")
$mainActivityNode.SetAttribute("hardwareAccelerated", $androidNs, "true")
$mainActivityNode.SetAttribute("windowSoftInputMode", $androidNs, "adjustResize")
# Ajouter NormalTheme s'il manque.
$hasNormalTheme = $false
foreach ($md in @($mainActivityNode.'meta-data')) {
if ($null -ne $md -and $md.GetAttribute("name", $androidNs) -eq "io.flutter.embedding.android.NormalTheme") {
$hasNormalTheme = $true
}
}
if (!$hasNormalTheme) {
$meta = $xml.CreateElement("meta-data")
$meta.SetAttribute("name", $androidNs, "io.flutter.embedding.android.NormalTheme")
$meta.SetAttribute("resource", $androidNs, "@style/NormalTheme")
[void]$mainActivityNode.PrependChild($meta)
}
# Ajouter launcher intent-filter s'il manque.
$hasLauncherFilter = $false
foreach ($f in @($mainActivityNode.'intent-filter')) {
if ($null -ne $f) {
foreach ($cat in @($f.category)) {
if ($cat.GetAttribute("name", $androidNs) -eq "android.intent.category.LAUNCHER") {
$hasLauncherFilter = $true
}
}
}
}
if (!$hasLauncherFilter) {
$filter = $xml.CreateElement("intent-filter")
$action = $xml.CreateElement("action")
$action.SetAttribute("name", $androidNs, "android.intent.action.MAIN")
[void]$filter.AppendChild($action)
$category = $xml.CreateElement("category")
$category.SetAttribute("name", $androidNs, "android.intent.category.LAUNCHER")
[void]$filter.AppendChild($category)
[void]$mainActivityNode.AppendChild($filter)
}
# Ajouter flutterEmbedding s'il manque.
$hasFlutterEmbedding = $false
foreach ($md in @($app.'meta-data')) {
if ($null -ne $md -and $md.GetAttribute("name", $androidNs) -eq "flutterEmbedding") {
$hasFlutterEmbedding = $true
}
}
if (!$hasFlutterEmbedding) {
$md = $xml.CreateElement("meta-data")
$md.SetAttribute("name", $androidNs, "flutterEmbedding")
$md.SetAttribute("value", $androidNs, "2")
[void]$app.AppendChild($md)
}
$xml.Save($Manifest)
Write-Host "AndroidManifest.xml mis a jour."
# 4) Nettoyer les MainActivity douteuses : ne pas supprimer, juste les afficher.
Write-Host ""
Write-Host "MainActivity detectees :"
Get-ChildItem (Join-Path $AndroidMain "kotlin") -Recurse -Filter MainActivity.kt -ErrorAction SilentlyContinue | ForEach-Object {
Write-Host " - $($_.FullName)"
}
Get-ChildItem (Join-Path $AndroidMain "java") -Recurse -Filter MainActivity.java -ErrorAction SilentlyContinue | ForEach-Object {
Write-Host " - $($_.FullName)"
}
# 5) Gradle : garder minSdk = 24, supprimer bloc Kotlin invalide si revenu.
if (Test-Path $GradleKts) {
$text = Get-Content $GradleKts -Raw
$text = [regex]::Replace($text, '(?ms)^\s*kotlin\s*\{\s*compilerOptions\s*\{.*?\}\s*\}\s*$', '')
$text = [regex]::Replace($text, 'minSdk\s*=\s*flutter\.minSdkVersion', 'minSdk = 24')
$text = [regex]::Replace($text, 'minSdk\s*=\s*\d+', 'minSdk = 24')
Set-Content -Path $GradleKts -Value $text -Encoding UTF8
Write-Host "build.gradle.kts verifie."
}
Set-Location $MobileDir
Write-Host ""
Write-Host "Nettoyage Flutter..."
flutter clean
flutter pub get
Write-Host ""
Write-Host "Reparation terminee."
Write-Host "Relance maintenant :"
Write-Host "flutter run"
Write-Host ""
Read-Host "Appuie sur Entree pour fermer"
@@ -0,0 +1,34 @@
# FIN65 - Réparation AndroidManifest.xml
Ce patch répare `AndroidManifest.xml` après l'erreur :
```text
Error parsing LocalFile: android/app/src/main/AndroidManifest.xml
Please ensure that the android manifest is a valid XML document
```
## Utilisation
Décompresse ce ZIP puis lance :
```powershell
.\repair_fin65_android_manifest.bat
```
Le script :
1. sauvegarde l'ancien Manifest cassé ;
2. réécrit un Manifest Flutter propre ;
3. garde :
- `android:label="FIN65"`
- `android:icon="@mipmap/ic_launcher"`
- `android:roundIcon="@mipmap/ic_launcher_round"`
- `android:usesCleartextTraffic="true"`
- permission Internet ;
4. lance `flutter clean` et `flutter pub get`.
Ensuite :
```powershell
cd D:\07-Logiciels\20-FIN65\fin65_mobile
flutter run
```
@@ -0,0 +1,11 @@
@echo off
setlocal
echo.
echo ==========================================
echo FIN65 - Repair AndroidManifest.xml
echo ==========================================
echo.
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0repair_fin65_android_manifest.ps1"
@@ -0,0 +1,83 @@
$MobileDir = "D:\07-Logiciels\20-FIN65\fin65_mobile"
$Manifest = Join-Path $MobileDir "android\app\src\main\AndroidManifest.xml"
Write-Host ""
Write-Host "=========================================="
Write-Host " FIN65 - Repair AndroidManifest.xml"
Write-Host "=========================================="
Write-Host ""
if (!(Test-Path $MobileDir)) {
Write-Host "[ERREUR] Dossier mobile introuvable : $MobileDir" -ForegroundColor Red
Read-Host "Appuie sur Entree pour fermer"
exit 1
}
$ManifestDir = Split-Path $Manifest -Parent
if (!(Test-Path $ManifestDir)) {
Write-Host "[ERREUR] Dossier Manifest introuvable : $ManifestDir" -ForegroundColor Red
Read-Host "Appuie sur Entree pour fermer"
exit 1
}
if (Test-Path $Manifest) {
$Stamp = Get-Date -Format "yyyyMMdd_HHmmss"
$Backup = "$Manifest.broken_$Stamp.bak"
Copy-Item $Manifest $Backup -Force
Write-Host "Sauvegarde de l'ancien Manifest :"
Write-Host $Backup
Write-Host ""
}
$Xml = @'
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="FIN65"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
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" />
</application>
</manifest>
'@
Set-Content -Path $Manifest -Value $Xml -Encoding UTF8
Write-Host "AndroidManifest.xml repare :"
Write-Host $Manifest
Write-Host ""
Set-Location $MobileDir
Write-Host "Verification Flutter..."
flutter clean
flutter pub get
Write-Host ""
Write-Host "Termine. Relance maintenant :"
Write-Host "flutter run"
Write-Host ""
Read-Host "Appuie sur Entree pour fermer"
@@ -0,0 +1,46 @@
# FIN65 - Fix activation empreinte V2.6.1
Ce patch corrige l'écran Sécurité.
## Problème
L'écran affichait :
```text
PIN actuel incorrect.
```
quand on essayait d'activer l'empreinte, parce que l'ancien écran utilisait le champ `PIN actuel` visible dans la page. C'était peu clair, donc naturellement ça a créé une erreur inutile. L'espèce humaine et les interfaces mal pensées, une vieille histoire.
## Correction
Maintenant, quand tu actives l'empreinte :
1. tu appuies sur l'interrupteur `Déverrouillage par empreinte` ;
2. une boîte de dialogue demande le PIN actuel ;
3. Android demande l'empreinte ;
4. si l'empreinte est validée, l'option est activée.
Le champ `PIN actuel` sur la page sert maintenant surtout à changer le PIN.
## Installation
Décompresse puis lance :
```powershell
.\apply_biometric_activation_ui_fix.bat
```
Ensuite :
```powershell
cd D:\07-Logiciels\20-FIN65\fin65_mobile
flutter run
```
## Test
1. Ouvre `Paramètres > Sécurité`.
2. Active `Déverrouillage par empreinte`.
3. Entre le PIN actuel dans la boîte de dialogue.
4. Pose le doigt quand Android le demande.
@@ -0,0 +1,62 @@
@echo off
setlocal
REM ============================================================
REM FIN65 - Fix activation empreinte V2.6.1
REM ============================================================
set "ROOT_DIR=D:\07-Logiciels\20-FIN65"
set "MOBILE_DIR=%ROOT_DIR%\fin65_mobile"
set "PUB_CACHE=%ROOT_DIR%\.pub-cache"
set "TARGET=%MOBILE_DIR%\lib\screens\security_screen.dart"
echo.
echo ==========================================
echo FIN65 - Fix activation empreinte V2.6.1
echo ==========================================
echo.
if not exist "%MOBILE_DIR%" (
echo [ERREUR] Dossier mobile introuvable :
echo %MOBILE_DIR%
pause
exit /b 1
)
if not exist "%MOBILE_DIR%\lib\screens" (
echo [ERREUR] Dossier screens introuvable :
echo %MOBILE_DIR%\lib\screens
pause
exit /b 1
)
for /f "tokens=1-4 delims=/ " %%a in ("%date%") do set DATESTAMP=%%d%%b%%c
set TIMESTAMP=%time::=%
set TIMESTAMP=%TIMESTAMP: =0%
set BACKUP=%TARGET%.before_biometric_ui_fix_%DATESTAMP%_%TIMESTAMP%.bak
if exist "%TARGET%" (
copy /Y "%TARGET%" "%BACKUP%" >nul
echo Sauvegarde :
echo %BACKUP%
echo.
)
copy /Y "%~dp0lib\screens\security_screen.dart" "%TARGET%" >nul
cd /d "%MOBILE_DIR%"
echo.
echo Nettoyage Flutter...
flutter clean
flutter pub get
echo.
echo ==========================================
echo Fix activation empreinte applique.
echo ==========================================
echo.
echo Relance maintenant :
echo flutter run
echo.
pause
@@ -0,0 +1,399 @@
import 'package:flutter/material.dart';
import 'package:local_auth/local_auth.dart';
import '../services/security_service.dart';
class SecurityScreen extends StatefulWidget {
const SecurityScreen({
super.key,
required this.securityService,
});
final SecurityService securityService;
@override
State<SecurityScreen> createState() => _SecurityScreenState();
}
class _SecurityScreenState extends State<SecurityScreen> {
final _currentPinController = TextEditingController();
final _newPinController = TextEditingController();
final _confirmPinController = TextEditingController();
bool _loading = true;
bool _saving = false;
bool _enabled = false;
bool _biometricAvailable = false;
bool _biometricEnabled = false;
List<BiometricType> _availableBiometrics = const [];
String? _message;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_currentPinController.dispose();
_newPinController.dispose();
_confirmPinController.dispose();
super.dispose();
}
Future<void> _load() async {
final enabled = await widget.securityService.isPinEnabled();
final biometricAvailable = await widget.securityService.isBiometricAvailable();
final biometricEnabled = await widget.securityService.isBiometricEnabled();
final availableBiometrics = await widget.securityService.getAvailableBiometrics();
if (!mounted) return;
setState(() {
_enabled = enabled;
_biometricAvailable = biometricAvailable;
_biometricEnabled = biometricEnabled;
_availableBiometrics = availableBiometrics;
_loading = false;
});
}
Future<bool> _verifyCurrentPinFromFieldIfNeeded() async {
if (!_enabled) return true;
final current = _currentPinController.text.trim();
final ok = await widget.securityService.verifyPin(current);
if (!ok) {
setState(() => _message = 'PIN actuel incorrect.');
return false;
}
return true;
}
Future<String?> _askCurrentPinDialog({
required String title,
required String actionLabel,
}) async {
final controller = TextEditingController();
final result = await showDialog<String>(
context: context,
barrierDismissible: true,
builder: (context) {
return AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: const InputDecoration(
labelText: 'PIN actuel',
border: OutlineInputBorder(),
counterText: '',
),
onSubmitted: (_) => Navigator.of(context).pop(controller.text.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
child: Text(actionLabel),
),
],
);
},
);
controller.dispose();
return result?.trim();
}
Future<bool> _askAndVerifyCurrentPin({
required String title,
required String actionLabel,
}) async {
final pin = await _askCurrentPinDialog(
title: title,
actionLabel: actionLabel,
);
if (pin == null) {
setState(() => _message = 'Action annulée.');
return false;
}
final ok = await widget.securityService.verifyPin(pin);
if (!ok) {
setState(() => _message = 'PIN actuel incorrect.');
return false;
}
return true;
}
Future<void> _savePin() async {
final newPin = _newPinController.text.trim();
final confirm = _confirmPinController.text.trim();
if (newPin != confirm) {
setState(() => _message = 'Le nouveau PIN et la confirmation ne correspondent pas.');
return;
}
setState(() {
_saving = true;
_message = null;
});
try {
final currentOk = await _verifyCurrentPinFromFieldIfNeeded();
if (!currentOk) return;
await widget.securityService.setPin(newPin);
_currentPinController.clear();
_newPinController.clear();
_confirmPinController.clear();
await _load();
setState(() {
_message = 'Verrouillage PIN activé.';
});
} catch (e) {
setState(() => _message = 'Erreur : $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _disablePin() async {
setState(() {
_saving = true;
_message = null;
});
try {
final currentOk = await _askAndVerifyCurrentPin(
title: 'Désactiver le verrouillage PIN',
actionLabel: 'Désactiver',
);
if (!currentOk) return;
await widget.securityService.disablePin();
_currentPinController.clear();
_newPinController.clear();
_confirmPinController.clear();
await _load();
setState(() {
_message = 'Verrouillage PIN et biométrie désactivés.';
});
} catch (e) {
setState(() => _message = 'Erreur : $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _toggleBiometrics(bool value) async {
if (_saving) return;
setState(() {
_saving = true;
_message = null;
});
try {
if (value) {
if (!_enabled) {
setState(() => _message = 'Active d’abord un PIN avant la biométrie.');
return;
}
if (!_biometricAvailable) {
setState(() => _message = 'Aucune empreinte/biométrie disponible sur ce téléphone.');
return;
}
final currentOk = await _askAndVerifyCurrentPin(
title: 'Activer l’empreinte',
actionLabel: 'Continuer',
);
if (!currentOk) return;
await widget.securityService.setBiometricEnabled(true);
final confirmed = await widget.securityService.authenticateWithBiometrics();
if (!confirmed) {
await widget.securityService.setBiometricEnabled(false);
setState(() => _message = 'Empreinte annulée ou refusée. Biométrie non activée.');
return;
}
} else {
final currentOk = await _askAndVerifyCurrentPin(
title: 'Désactiver l’empreinte',
actionLabel: 'Désactiver',
);
if (!currentOk) return;
await widget.securityService.setBiometricEnabled(false);
}
await _load();
setState(() {
_message = value
? 'Déverrouillage par empreinte activé.'
: 'Déverrouillage par empreinte désactivé.';
});
} catch (e) {
setState(() => _message = 'Erreur : $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
String _biometricLabel() {
if (_availableBiometrics.isEmpty) return 'Aucune biométrie détectée.';
final labels = _availableBiometrics.map((type) {
return switch (type) {
BiometricType.fingerprint => 'empreinte',
BiometricType.face => 'visage',
BiometricType.iris => 'iris',
BiometricType.strong => 'biométrie forte',
BiometricType.weak => 'biométrie faible',
};
}).toSet().join(', ');
return 'Disponible : $labels.';
}
@override
Widget build(BuildContext context) {
if (_loading) {
return Scaffold(
appBar: AppBar(title: const Text('Sécurité')),
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(title: const Text('Sécurité')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: SwitchListTile(
value: _enabled,
onChanged: null,
secondary: Icon(_enabled ? Icons.lock : Icons.lock_open),
title: const Text('Verrouillage PIN'),
subtitle: Text(
_enabled
? 'Activé. L’app se verrouille à la fermeture ou au changement d’application.'
: 'Désactivé. Tes données locales sont accessibles sans PIN.',
),
),
),
if (_enabled) ...[
const SizedBox(height: 12),
Card(
child: SwitchListTile(
value: _biometricEnabled,
onChanged: _saving || !_biometricAvailable ? null : _toggleBiometrics,
secondary: const Icon(Icons.fingerprint),
title: const Text('Déverrouillage par empreinte'),
subtitle: Text(
_biometricAvailable
? '${_biometricLabel()} Appuie sur l’interrupteur, entre le PIN actuel, puis valide l’empreinte.'
: 'Aucune empreinte/biométrie disponible ou configurée sur ce téléphone.',
),
),
),
],
const SizedBox(height: 16),
if (_enabled) ...[
TextField(
controller: _currentPinController,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: const InputDecoration(
labelText: 'PIN actuel',
border: OutlineInputBorder(),
helperText: 'Uniquement nécessaire pour changer le PIN.',
counterText: '',
),
),
const SizedBox(height: 12),
],
TextField(
controller: _newPinController,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: InputDecoration(
labelText: _enabled ? 'Nouveau PIN' : 'PIN',
border: const OutlineInputBorder(),
helperText: '4 à 8 chiffres.',
counterText: '',
),
),
const SizedBox(height: 12),
TextField(
controller: _confirmPinController,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: const InputDecoration(
labelText: 'Confirmer le PIN',
border: OutlineInputBorder(),
counterText: '',
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _saving ? null : _savePin,
icon: const Icon(Icons.save),
label: Text(_enabled ? 'Changer le PIN' : 'Activer le PIN'),
),
if (_enabled) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _saving ? null : _disablePin,
icon: const Icon(Icons.lock_open),
label: const Text('Désactiver le PIN'),
),
],
if (_message != null) ...[
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(_message!),
),
),
],
const SizedBox(height: 24),
const Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
'Le PIN protège l’accès local à l’application. L’empreinte utilise la sécurité biométrique du téléphone. Le PIN reste le secours si l’empreinte échoue.',
),
),
),
],
),
);
}
}

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