diff --git a/CLAUDE.md b/CLAUDE.md index 364f5e43..40c6561d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,19 @@ flutter test --coverage - **Distribution régionale** : répartition des tirs par quadrant - Filtrage par période : session, semaine, mois, toutes les sessions +### Sauvegarde (export / import JSON) +- Bouton **Exporter** en bas de l'écran Statistiques : génère un fichier JSON + (sessions + cibles + impacts + calibration, armurerie + entretien, et un + instantané des statistiques calculées) puis ouvre la feuille de partage du + système (`share_plus`) pour l'envoyer où l'on veut +- Option « Inclure les photos des cibles » : photos encodées en base64 dans le + JSON (sauvegarde complète mais fichier lourd) ; sans elles le fichier reste léger +- Bouton **Importer** (`file_selector`) : aperçu du contenu avant confirmation, + puis fusion avec les données existantes — même identifiant = mise à jour, + donc réimporter deux fois ne crée pas de doublon et rien n'est effacé +- Les statistiques ne sont pas réimportées : elles sont recalculées à partir des + sessions. Elles figurent dans le fichier pour être exploitables telles quelles + ### Historique des sessions - Sauvegarde des sessions avec date, score, notes - Visualisation des sessions passées @@ -121,3 +134,4 @@ session_list_item.dart Item de liste représentant une session history_chart.dart Graphique d'évolution des 10 dernières sessions statistics_screen.dart Écran statistiques avec filtrage par période heat_map_widget.dart Heat map avec gradient bleu→rouge +backup_service.dart Export/import JSON des sessions, stats et armurerie diff --git a/lib/data/database/database_helper.dart b/lib/data/database/database_helper.dart index 52681b6b..3fd3978d 100644 --- a/lib/data/database/database_helper.dart +++ b/lib/data/database/database_helper.dart @@ -590,6 +590,16 @@ class DatabaseHelper { ); } + /// Toutes les entrées de maintenance, armes confondues (export/sauvegarde). + Future> getAllMaintenance() async { + final db = await database; + final maps = await db.query( + AppConstants.maintenanceTable, + orderBy: 'date DESC', + ); + return List.generate(maps.length, (i) => MaintenanceEntry.fromMap(maps[i])); + } + Future> getMaintenanceForWeapon(String weaponId) async { final db = await database; final maps = await db.query( diff --git a/lib/data/repositories/session_repository.dart b/lib/data/repositories/session_repository.dart index ebe684bf..027ae7ad 100644 --- a/lib/data/repositories/session_repository.dart +++ b/lib/data/repositories/session_repository.dart @@ -131,6 +131,29 @@ class SessionRepository { return await _databaseHelper.getStatistics(); } + /// Enregistre une session déjà construite (import de sauvegarde). + /// Les identifiants existants sont écrasés : réimporter deux fois la même + /// sauvegarde ne crée pas de doublons. + Future saveSession(Session session) async { + await _databaseHelper.insertSession(session); + } + + /// Copie une image dans le dossier des cibles de l'app (import de sauvegarde). + Future saveImageBytes(List bytes, String extension) async { + final appDir = await getApplicationDocumentsDirectory(); + final imagesDir = Directory(path.join(appDir.path, 'target_images')); + + if (!await imagesDir.exists()) { + await imagesDir.create(recursive: true); + } + + final fileName = '${_uuid.v4()}$extension'; + final destPath = path.join(imagesDir.path, fileName); + await File(destPath).writeAsBytes(bytes); + + return destPath; + } + String generateId() { return _uuid.v4(); } @@ -170,6 +193,11 @@ class SessionRepository { return await _databaseHelper.getAllWeapons(); } + /// Enregistre une arme déjà construite (import de sauvegarde). + Future saveWeapon(Weapon weapon) async { + await _databaseHelper.insertWeapon(weapon); + } + Future updateWeapon(Weapon weapon) async { await _databaseHelper.updateWeapon(weapon); } @@ -208,6 +236,16 @@ class SessionRepository { return await _databaseHelper.getMaintenanceForWeapon(weaponId); } + /// Tout l'historique de maintenance de l'armurerie (export de sauvegarde). + Future> getAllMaintenance() async { + return await _databaseHelper.getAllMaintenance(); + } + + /// Enregistre une entrée de maintenance déjà construite (import). + Future saveMaintenanceEntry(MaintenanceEntry entry) async { + await _databaseHelper.insertMaintenance(entry); + } + Future deleteMaintenanceEntry(String id) async { await _databaseHelper.deleteMaintenance(id); } diff --git a/lib/features/garage/weapon_list_screen.dart b/lib/features/garage/weapon_list_screen.dart index b119d4ae..9536fe56 100644 --- a/lib/features/garage/weapon_list_screen.dart +++ b/lib/features/garage/weapon_list_screen.dart @@ -7,7 +7,12 @@ import '../../data/repositories/session_repository.dart'; import 'weapon_detail_screen.dart'; class WeaponListScreen extends StatefulWidget { - const WeaponListScreen({super.key}); + /// Incrémenté par la navigation à chaque ouverture de l'onglet Armurerie + /// (l'écran est gardé vivant par l'IndexedStack et ne se rafraîchit pas + /// seul : sans ça, un import de sauvegarde resterait invisible). + final int refreshTick; + + const WeaponListScreen({super.key, this.refreshTick = 0}); @override State createState() => _WeaponListScreenState(); @@ -23,6 +28,14 @@ class _WeaponListScreenState extends State { _loadWeapons(); } + @override + void didUpdateWidget(WeaponListScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.refreshTick != widget.refreshTick) { + _loadWeapons(); + } + } + Future _loadWeapons() async { final repository = context.read(); final weapons = await repository.getWeapons(); diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index 31fb35c4..5eb28c6a 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -15,7 +15,12 @@ import '../session/session_provider.dart'; import 'widgets/stats_card.dart'; class HomeScreen extends StatefulWidget { - const HomeScreen({super.key}); + /// Incrémenté par la navigation à chaque ouverture de l'onglet Accueil + /// (l'écran est gardé vivant par l'IndexedStack : sans ça, un import de + /// sauvegarde resterait invisible sur le dashboard). + final int refreshTick; + + const HomeScreen({super.key, this.refreshTick = 0}); @override State createState() => _HomeScreenState(); @@ -35,6 +40,14 @@ class _HomeScreenState extends State { _loadStats(); } + @override + void didUpdateWidget(HomeScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.refreshTick != widget.refreshTick) { + _loadStats(); + } + } + @override void didChangeDependencies() { super.didChangeDependencies(); diff --git a/lib/features/statistics/statistics_screen.dart b/lib/features/statistics/statistics_screen.dart index eb5c5d3e..13ac461e 100644 --- a/lib/features/statistics/statistics_screen.dart +++ b/lib/features/statistics/statistics_screen.dart @@ -1,11 +1,16 @@ +import 'dart:io'; import 'dart:math' as math; import 'dart:ui' as ui; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes +import 'package:path/path.dart' as p; +import 'package:share_plus/share_plus.dart'; import '../../core/widgets/metric_info_button.dart'; import '../../data/models/session.dart'; import '../../data/repositories/session_repository.dart'; +import '../../services/backup_service.dart'; import '../../services/statistics_service.dart'; class StatisticsScreen extends StatefulWidget { @@ -43,6 +48,10 @@ class _StatisticsScreenState extends State { bool _showingB = false; // false = on affiche A, true = on affiche B bool get _compareMode => _compareA != null && _compareB != null; + // --- Sauvegarde (export/import JSON) --- + bool _isBackupBusy = false; + final GlobalKey _exportButtonKey = GlobalKey(); + @override void initState() { super.initState(); @@ -374,6 +383,11 @@ class _StatisticsScreenState extends State { _buildBiasWarning(), ], + const SizedBox(height: 25), + + // 5. SAUVEGARDE : export/import de toutes les données + _buildBackupSection(), + const SizedBox(height: 30), ], ), @@ -882,6 +896,263 @@ class _StatisticsScreenState extends State { ), ); } + + // --- SAUVEGARDE : EXPORT / IMPORT JSON --- + + Widget _buildBackupSection() { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.save_alt, color: theme.textTheme.titleMedium?.color, size: 20), + const SizedBox(width: 8), + Text( + 'Sauvegarde', + style: TextStyle( + color: theme.textTheme.titleMedium?.color, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Exporte toutes tes sessions, tes stats et ton armurerie dans un ' + 'fichier JSON, à envoyer où tu veux. L\'import fusionne le fichier ' + 'avec tes données actuelles (rien n\'est effacé).', + style: TextStyle( + color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), + fontSize: 12, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + key: _exportButtonKey, + onPressed: _isBackupBusy ? null : _exportBackup, + icon: const Icon(Icons.ios_share, size: 18), + label: const Text('Exporter'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: _isBackupBusy ? null : _importBackup, + icon: const Icon(Icons.file_download_outlined, size: 18), + label: const Text('Importer'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + ], + ), + if (_isBackupBusy) ...[ + const SizedBox(height: 12), + const LinearProgressIndicator(minHeight: 2), + ], + ], + ), + ); + } + + BackupService _backupService() => + BackupService(repository: context.read()); + + Future _exportBackup() async { + final includeImages = await _askIncludeImages(); + if (includeImages == null || !mounted) return; + + setState(() => _isBackupBusy = true); + try { + final file = await _backupService().exportToFile( + includeImages: includeImages, + ); + if (!mounted) return; + + // sharePositionOrigin : obligatoire pour l'iPad, ignoré ailleurs. + final box = _exportButtonKey.currentContext?.findRenderObject() as RenderBox?; + final origin = box != null && box.hasSize + ? box.localToGlobal(Offset.zero) & box.size + : null; + + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path, mimeType: 'application/json')], + fileNameOverrides: [p.basename(file.path)], + subject: 'Sauvegarde IMPACT', + text: 'Sauvegarde de mes sessions de tir (${p.basename(file.path)})', + sharePositionOrigin: origin, + ), + ); + } catch (e) { + _showMessage('Export impossible : $e', isError: true); + } finally { + if (mounted) setState(() => _isBackupBusy = false); + } + } + + /// Les photos de cibles alourdissent énormément le fichier : on laisse le + /// choix entre une sauvegarde légère (données seules) et une sauvegarde + /// complète (photos encodées dans le JSON). + Future _askIncludeImages() { + var includeImages = false; + return showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Text('Exporter mes données'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Le fichier contiendra toutes tes sessions (cibles, impacts, ' + 'scores), tes statistiques et ton armurerie.', + ), + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: includeImages, + onChanged: (v) => setDialogState(() => includeImages = v), + title: const Text('Inclure les photos des cibles'), + subtitle: const Text( + 'Sauvegarde complète, mais fichier beaucoup plus lourd.', + style: TextStyle(fontSize: 12), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, includeImages), + child: const Text('Exporter'), + ), + ], + ), + ), + ); + } + + Future _importBackup() async { + const jsonGroup = XTypeGroup( + label: 'Sauvegarde IMPACT (.json)', + extensions: ['json'], + // Android renvoie parfois un type générique pour un .json : on accepte + // large, le contenu est validé à la lecture de toute façon. + mimeTypes: ['application/json', 'text/plain', 'application/octet-stream'], + uniformTypeIdentifiers: ['public.json', 'public.text'], + ); + + final picked = await openFile(acceptedTypeGroups: const [jsonGroup]); + if (picked == null || !mounted) return; + + setState(() => _isBackupBusy = true); + try { + final service = _backupService(); + final preview = await service.readBackup(File(picked.path)); + if (!mounted) return; + + final confirmed = await _confirmImport(preview); + if (confirmed != true || !mounted) return; + + final result = await service.applyBackup(preview); + await _loadStatistics(); + if (!mounted) return; + + final details = [ + '${result.sessions} session(s)', + '${result.weapons} arme(s)', + if (result.maintenance > 0) '${result.maintenance} entretien(s)', + if (result.images > 0) '${result.images} photo(s)', + ].join(' · '); + _showMessage( + result.errors.isEmpty + ? 'Import terminé : $details' + : 'Import terminé : $details — ${result.errors.length} entrée(s) ignorée(s)', + ); + } on BackupFormatException catch (e) { + _showMessage(e.message, isError: true); + } catch (e) { + _showMessage('Import impossible : $e', isError: true); + } finally { + if (mounted) setState(() => _isBackupBusy = false); + } + } + + Future _confirmImport(BackupPreview preview) { + final date = preview.exportedAt; + final dateLabel = date == null + ? null + : '${date.day.toString().padLeft(2, '0')}/' + '${date.month.toString().padLeft(2, '0')}/${date.year}'; + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Importer cette sauvegarde ?'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (dateLabel != null) Text('Exportée le $dateLabel'), + if (dateLabel != null) const SizedBox(height: 8), + Text('• ${preview.sessionCount} session(s)'), + Text('• ${preview.targetCount} cible(s), ${preview.shotCount} impact(s)'), + Text('• ${preview.weaponCount} arme(s), ${preview.maintenanceCount} entretien(s)'), + Text(preview.hasImages + ? '• Photos des cibles incluses' + : '• Sans photos de cibles'), + const SizedBox(height: 12), + const Text( + 'Les données actuelles sont conservées. Une session déjà ' + 'présente est simplement mise à jour.', + style: TextStyle(fontSize: 12), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Importer'), + ), + ], + ), + ); + } + + void _showMessage(String message, {bool isError = false}) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red.shade700 : null, + duration: const Duration(seconds: 4), + ), + ); + } } class _HeatMapPainter extends CustomPainter { diff --git a/lib/main_navigation_holder.dart b/lib/main_navigation_holder.dart index 81685b73..fa1e7f53 100644 --- a/lib/main_navigation_holder.dart +++ b/lib/main_navigation_holder.dart @@ -37,23 +37,27 @@ class _MainNavigationHolderState extends State { // rafraîchissent pas seuls). int _statsTick = 0; int _historyTick = 0; + int _homeTick = 0; + int _garageTick = 0; void selectTab(int index) { if (!mounted) return; setState(() { _selectedIndex = index; + if (index == mainTabHome) _homeTick++; if (index == mainTabHistory) _historyTick++; if (index == mainTabStats) _statsTick++; + if (index == mainTabGarage) _garageTick++; }); } @override Widget build(BuildContext context) { final screens = [ - const HomeScreen(), + HomeScreen(refreshTick: _homeTick), HistoryScreen(refreshTick: _historyTick), StatisticsScreen(refreshTick: _statsTick), - const WeaponListScreen(), + WeaponListScreen(refreshTick: _garageTick), ]; return Scaffold( body: IndexedStack( diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 64a0ecea..7299b5cf 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index a630fa93..886932bf 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 0ad64337..7a04c318 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,14 @@ import Foundation import device_info_plus import file_selector_macos +import share_plus import shared_preferences_foundation import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 094bf834..64df251d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -201,6 +201,30 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "89243030ea4b3463fb402b44d5eeacc4ccb1c46a88870cb2a5080d693200c1ed" + url: "https://pub.dev" + source: hosted + version: "0.5.2+6" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" file_selector_linux: dependency: transitive description: @@ -225,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" file_selector_windows: dependency: transitive description: @@ -709,6 +741,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" + url: "https://pub.dev" + source: hosted + version: "13.3.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" + url: "https://pub.dev" + source: hosted + version: "7.2.0" shared_preferences: dependency: "direct main" description: @@ -898,6 +946,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index b86a7964..d18586f7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,6 +70,8 @@ dependencies: shared_preferences: ^2.5.5 crypto: ^3.0.7 camera: ^0.12.0+1 + share_plus: ^13.3.0 + file_selector: ^1.1.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 2c256bd4..e5d94855 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,16 @@ #include #include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 5a997e6a..fd3a3fbc 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,8 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows permission_handler_windows + share_plus + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST