Initial upload: fin65-mobile
@@ -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
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
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
|
||||
|
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>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
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.',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||