diff --git a/assets/icons/cloud_save.png b/assets/icons/cloud_save.png new file mode 100644 index 00000000..1bd25b89 Binary files /dev/null and b/assets/icons/cloud_save.png differ diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart index a3aa4be3..a4c86543 100644 --- a/lib/features/analysis/analysis_screen.dart +++ b/lib/features/analysis/analysis_screen.dart @@ -701,169 +701,278 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { ); } - void _showSaveSessionDialog(BuildContext context, AnalysisProvider provider) { + Future _showSaveSessionDialog( + BuildContext context, + AnalysisProvider provider, + ) async { + // L'option « Participer à l'entraînement IA » (Paramètres) est lue AVANT + // d'ouvrir la popup : elle décide de la présence du bouton d'export. + final canExport = + await WalletIdentityService().isUploadEnabled() && + provider.state == AnalysisState.success; + + if (!context.mounted) return; + showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Session Terminee'), + // Protège des petits écrans / grandes polices : la popup défile au + // lieu de déborder. + scrollable: true, + // En-tête bleu pleine largeur : le titre occupe toute la bande, d'où + // les paddings mis à zéro. + titlePadding: EdgeInsets.zero, + contentPadding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + clipBehavior: Clip.antiAlias, + title: Container( + width: double.infinity, + color: AppTheme.primaryColor, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: const Row( + children: [ + Icon(Icons.flag, color: Colors.white), + SizedBox(width: 10), + Text( + 'Session terminée', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + // Les boutons sont dans le contenu (et non dans `actions`) pour être + // tous à la même largeur, alignés les uns sous les autres. content: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('Nombre de tirs: ${provider.shotCount}'), - Text('Score total: ${provider.totalScore}'), + _buildDialogRecap(context, provider), const SizedBox(height: 16), const Text('Voulez-vous enregistrer cette session ?'), - // Export vers le backend IA : proposé ici (et non plus dans - // l'AppBar). Visible seulement si l'option est activée dans les - // Paramètres ET si l'analyse a réussi. - FutureBuilder( - future: WalletIdentityService().isUploadEnabled(), - builder: (context, snapshot) { - final isEnabled = snapshot.data ?? false; - if (!isEnabled || - provider.state != AnalysisState.success) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.only(top: 12), - child: OutlinedButton.icon( - icon: const Icon(Icons.cloud_upload), - label: const Text('Exporter pour IA'), - onPressed: () async { - if (provider.state != AnalysisState.success) return; + const SizedBox(height: 16), + _buildDialogButton( + icon: const Icon(Icons.add_a_photo, color: Colors.white), + label: 'AJOUTER UNE CIBLE', + color: AppTheme.secondaryColor, + onPressed: () => _saveAndAddTarget(context, provider), + ), + const SizedBox(height: 8), + _buildDialogButton( + icon: const Icon(Icons.save, color: Colors.white), + label: 'TERMINER TOUT', + color: AppTheme.primaryColor, + onPressed: () => _finishSession(context, provider), + ), + // Bouton d'export : uniquement si l'entraînement IA est autorisé. + if (canExport) ...[ + const SizedBox(height: 8), + _buildDialogButton( + icon: Image.asset( + 'assets/icons/cloud_save.png', + width: 24, + height: 24, + // L'icône est un trait noir : on la recolore en blanc pour + // qu'elle ressorte sur le bouton. + color: Colors.white, + ), + label: 'TERMINER TOUT ET EXPORTER', + color: AppTheme.warningColor, + onPressed: () => + _finishSession(context, provider, export: true), + ), + ], + const SizedBox(height: 4), + TextButton( + onPressed: () { + Navigator.pop(context); + // CORRECTION : on repart aussi de l'image SOURCE non rognée ici. + final path = _backCropImagePath(provider); + final type = provider.targetType!; - // On capture messenger et session AVANT l'await pour - // éviter tout usage de context après un gap async. - final messenger = ScaffoldMessenger.of(context); - final sp = context.read(); - - messenger.showSnackBar( - const SnackBar( - content: Text('Exportation en cours...'), - ), - ); - - final success = await provider.exportToAiBackend( - sessionId: sp.activeSessionId, - distance: sp.distance, - weapon: sp.currentWeapon, - ); - - messenger.hideCurrentSnackBar(); - messenger.showSnackBar( - SnackBar( - content: Text( - success - ? 'Export réussi vers le backend IA !' - : (provider.errorMessage ?? - 'Erreur d\'export'), - ), - backgroundColor: success - ? AppTheme.successColor - : AppTheme.errorColor, - ), - ); - }, + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => CropScreen( + imagePath: path, + targetType: type, + initialScale: widget.cropScale, + initialOffset: widget.cropOffset, + ), ), ); }, + child: const Text('ANNULER'), ), ], ), - actions: [ - TextButton( - onPressed: () { - Navigator.pop(context); - // CORRECTION : on repart aussi de l'image SOURCE non rognée ici. - final path = _backCropImagePath(provider); - final type = provider.targetType!; + ), + ); + } - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => CropScreen( - imagePath: path, - targetType: type, - initialScale: widget.cropScale, - initialOffset: widget.cropOffset, - ), - ), - ); - }, - child: const Text('ANNULER'), - ), - ElevatedButton( - onPressed: () async { - try { - final sessionProvider = context.read(); - final analysis = await provider.saveSession( - sessionId: sessionProvider.activeSessionId, - weaponName: sessionProvider.currentWeapon, - weaponId: sessionProvider.currentWeaponId, - distance: sessionProvider.distance, - date: sessionProvider.sessionDate, - ); - - sessionProvider.addAnalysis(analysis); - - if (context.mounted) { - Navigator.pop(context); - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute( - builder: (context) => const CaptureScreen(), - ), - (route) => route.isFirst, - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Erreur: $e'), - backgroundColor: AppTheme.errorColor, - ), - ); - } - } - }, - child: const Text('AJOUTER UNE CIBLE'), - ), - ElevatedButton( - onPressed: () async { - try { - final sessionProvider = context.read(); - await provider.saveSession( - sessionId: sessionProvider.activeSessionId, - weaponName: sessionProvider.currentWeapon, - weaponId: sessionProvider.currentWeaponId, - distance: sessionProvider.distance, - date: sessionProvider.sessionDate, - ); - - if (context.mounted) { - sessionProvider.endSession(); - Navigator.pop(context); - Navigator.of(context).popUntil((route) => route.isFirst); - // Fin de session : on atterrit sur les statistiques. - openMainTab(mainTabStats); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Erreur: $e'), - backgroundColor: AppTheme.errorColor, - ), - ); - } - } - }, - child: const Text('TERMINER TOUT'), - ), + /// Rappel chiffré (tirs / score) en tête de la popup de fin de session. + Widget _buildDialogRecap(BuildContext context, AnalysisProvider provider) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildRecapValue(context, '${provider.shotCount}', 'Tirs'), + _buildRecapValue(context, '${provider.totalScore}', 'Score total'), ], ), ); } + + Widget _buildRecapValue(BuildContext context, String value, String label) { + return Column( + children: [ + Text( + value, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + Text(label, style: Theme.of(context).textTheme.bodySmall), + ], + ); + } + + /// Bouton pleine largeur de la popup, aux couleurs du thème. + Widget _buildDialogButton({ + required Widget icon, + required String label, + required Color color, + required VoidCallback onPressed, + }) { + return ElevatedButton.icon( + icon: icon, + label: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: onPressed, + ); + } + + /// Enregistre la cible courante et enchaîne sur une nouvelle capture. + Future _saveAndAddTarget( + BuildContext context, + AnalysisProvider provider, + ) async { + try { + final sessionProvider = context.read(); + final analysis = await provider.saveSession( + sessionId: sessionProvider.activeSessionId, + weaponName: sessionProvider.currentWeapon, + weaponId: sessionProvider.currentWeaponId, + distance: sessionProvider.distance, + date: sessionProvider.sessionDate, + ); + + sessionProvider.addAnalysis(analysis); + + if (context.mounted) { + Navigator.pop(context); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const CaptureScreen()), + (route) => route.isFirst, + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: $e'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + } + } + + /// Clôture la session et revient sur l'onglet Statistiques. + /// + /// Avec [export], la cible est en plus envoyée au backend d'entraînement IA. + /// L'enregistrement local reste prioritaire : un échec d'export n'empêche + /// jamais la session d'être sauvegardée. + Future _finishSession( + BuildContext context, + AnalysisProvider provider, { + bool export = false, + }) async { + // Messenger et session capturés AVANT les await : le contexte de la popup + // ne sera plus valide ensuite. + final messenger = ScaffoldMessenger.of(context); + final sessionProvider = context.read(); + + try { + await provider.saveSession( + sessionId: sessionProvider.activeSessionId, + weaponName: sessionProvider.currentWeapon, + weaponId: sessionProvider.currentWeaponId, + distance: sessionProvider.distance, + date: sessionProvider.sessionDate, + ); + + bool? exportSucceeded; + if (export) { + messenger.showSnackBar( + const SnackBar(content: Text('Exportation en cours...')), + ); + exportSucceeded = await provider.exportToAiBackend( + sessionId: sessionProvider.activeSessionId, + distance: sessionProvider.distance, + weapon: sessionProvider.currentWeapon, + ); + messenger.hideCurrentSnackBar(); + } + + if (context.mounted) { + sessionProvider.endSession(); + Navigator.pop(context); + Navigator.of(context).popUntil((route) => route.isFirst); + // Fin de session : on atterrit sur les statistiques. + openMainTab(mainTabStats); + } + + if (exportSucceeded != null) { + messenger.showSnackBar( + SnackBar( + content: Text( + exportSucceeded + ? 'Export réussi vers le backend IA !' + : (provider.errorMessage ?? 'Erreur d\'export'), + ), + backgroundColor: exportSucceeded + ? AppTheme.successColor + : AppTheme.errorColor, + ), + ); + } + } catch (e) { + messenger.showSnackBar( + SnackBar( + content: Text('Erreur: $e'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + } } \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index a4d0b03a..b86a7964 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -93,10 +93,8 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/icons/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images