feat(session): swap the AI export button for a per-session switch

The end-of-session dialog no longer carries a second "finish" button.
"TERMINER TOUT" is now the only way out, and what it does with the target
depends on the AI training setting:

- setting on: the target is always sent, with a discreet reminder saying so;
- setting off: a "CONTRIBUER À L'IA" switch (off by default, never
  remembered) decides whether the target is sent or kept on the device;
- banned account or failed analysis: neither switch nor upload.

Turning the switch on for the first time shows the full program disclaimer
(what is sent, pseudonymity, banning rules); refusing leaves it off. That
text moved to a shared showAiConsentDialog() so the settings switch and the
session switch cannot drift apart, and a new is_ai_terms_accepted flag
remembers the acceptance without silently enabling the global setting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
qlionbleusam
2026-09-09 14:15:03 +02:00
co-authored by Claude Opus 5
parent a9651588bb
commit ba58cf8efc
5 changed files with 342 additions and 145 deletions
+112 -23
View File
@@ -14,6 +14,7 @@ import 'package:provider/provider.dart';
import '../../main_navigation_holder.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/ai_consent_dialog.dart';
import '../../data/models/target_type.dart';
import '../../data/repositories/session_repository.dart';
import '../../services/score_calculator_service.dart';
@@ -708,11 +709,21 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
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;
// Les réglages du programme IA sont lus AVANT d'ouvrir la popup :
// - option « Participer à l'entraînement IA » activée (Paramètres) :
// aucun switch, « TERMINER TOUT » envoie systématiquement la cible ;
// - option désactivée : un switch (OFF par défaut) propose l'envoi pour
// cette session seulement ;
// - compte banni ou analyse en échec : ni switch ni envoi.
final wallet = WalletIdentityService();
final isBanned = await wallet.isBanned();
final alwaysExport = await wallet.isUploadEnabled();
final canExport = !isBanned && provider.state == AnalysisState.success;
final showExportSwitch = canExport && !alwaysExport;
// Choix ponctuel, jamais mémorisé d'une session à l'autre : le switch
// repart toujours de OFF pour qu'aucune photo ne parte sans geste explicite.
bool exportThisSession = false;
if (!context.mounted) return;
@@ -751,7 +762,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
),
// 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(
content: StatefulBuilder(
builder: (context, setDialogState) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -765,29 +777,59 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
color: AppTheme.secondaryColor,
onPressed: () => _saveAndAddTarget(context, provider),
),
// Switch d'envoi ponctuel : il remplace l'ancien bouton
// « TERMINER ET CONTRIBUER À L'IA » et n'apparaît que si l'option
// globale des Paramètres est désactivée.
if (showExportSwitch) ...[
const SizedBox(height: 8),
_buildExportSwitch(
context: context,
value: exportThisSession,
onChanged: (value) async {
if (!value) {
setDialogState(() => exportThisSession = false);
return;
}
// Les règles du programme (contenu envoyé, pseudonymat,
// bannissement) sont présentées une seule fois : refuser
// laisse le switch sur OFF.
if (!await wallet.hasAcceptedAiTerms()) {
if (!context.mounted) return;
if (!await showAiConsentDialog(context)) return;
await wallet.setAiTermsAccepted(true);
}
setDialogState(() => exportThisSession = true);
},
),
],
const SizedBox(height: 8),
_buildDialogButton(
icon: const Icon(Icons.save, color: Colors.white),
label: 'TERMINER TOUT',
color: AppTheme.primaryColor,
onPressed: () => _finishSession(context, provider),
onPressed: () => _finishSession(
context,
provider,
export: canExport && (alwaysExport || exportThisSession),
),
),
// 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 ET CONTRIBUER À L\'IA',
color: AppTheme.warningColor,
onPressed: () =>
_finishSession(context, provider, export: true),
// Rappel discret quand l'envoi est acquis par les Paramètres :
// sans switch, l'utilisateur doit savoir que « TERMINER TOUT »
// contribue aussi à l'IA.
if (canExport && alwaysExport) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.psychology,
size: 14, color: AppTheme.warningColor),
const SizedBox(width: 6),
Expanded(
child: Text(
'Cette cible sera envoyée au programme d\'entraînement IA.',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
],
const SizedBox(height: 4),
@@ -813,6 +855,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
child: const Text('ANNULER'),
),
],
),
),
),
);
@@ -873,6 +916,52 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
);
}
/// Switch « Contribuer à l'IA » de la popup de fin de session.
///
/// Sur ON, « TERMINER TOUT » enverra en plus la cible au backend
/// d'entraînement ; sur OFF, la session est simplement enregistrée en local.
Widget _buildExportSwitch({
required BuildContext context,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.warningColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: AppTheme.warningColor.withValues(alpha: value ? 0.8 : 0.35),
),
),
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
secondary: Image.asset(
'assets/icons/cloud_save.png',
width: 24,
height: 24,
// L'icône est un trait noir : on la recolore pour la rendre lisible
// sur les deux thèmes.
color: AppTheme.warningColor,
),
title: const Text(
'CONTRIBUER À L\'IA',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
subtitle: Text(
value
? 'La cible sera envoyée au serveur d\'entraînement.'
: 'La cible reste sur votre appareil.',
style: Theme.of(context).textTheme.bodySmall,
),
value: value,
activeThumbColor: AppTheme.warningColor,
onChanged: onChanged,
),
);
}
/// Enregistre la cible courante et enchaîne sur une nouvelle capture.
Future<void> _saveAndAddTarget(
BuildContext context,