From b51c6c16d53c9fdc8c4ac51c8f5c150f3a0f5f96 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:50:19 +0200 Subject: [PATCH] Add mission/drone profile sharing and importing, fix map not fitting to loaded mission Sharing/Import (Doku 3.5/3.6), analog shareMission()/shareDrone() und den importMissionInput/importDroneInput-Handlern im HTML-Demonstrator: - services/sharing/mission_share_codec.dart, drone_share_codec.dart: reine Funktionen zum Bauen/Parsen des Export-JSON (appVersion/exportedAt-Umschlag), inkl. Erkennung einer abweichenden Hauptversion beim Import - vollstaendig unit-getestet ohne Plugin-Abhaengigkeit. - services/database/waypoint_json_codec.dart: Waypoint-JSON-(De-)Serialisierung aus mission_repository.dart herausgezogen, damit Persistenz und Sharing exakt dasselbe Dateiformat verwenden statt es zu duplizieren. - services/sharing/sharing_service.dart: duenne I/O-Schicht - schreibt eine temporaere Datei und oeffnet das native Share-Sheet (share_plus), bzw. liest eine vom Nutzer per Systemdialog ausgewaehlte JSON-Datei (file_picker). Als Klasse mit Instanzmethoden gehalten, damit sie sich in Tests durch einen Fake ersetzen laesst. - ui/widgets/missions_drones_panel.dart: Share-Icon je Zeile, Import-Button im Toolbar beider Tabs. Paket-Versionen bewusst gewaehlt: file_picker 11.0.2/share_plus 11.x wurden zunaechst wegen einer win32-Konflikt-Aufloesung genutzt, kompilierten auf diesem Projekt (AGP 9.0.1) aber nicht - file_picker < 12.0.0-beta.1 prueft nur AGP-Version >= 9 und ueberspringt dann das Anwenden des Kotlin-Android- Plugins, in der Annahme, AGPs eingebauter Kotlin-Support wuerde das uebernehmen, was hier zu einem fehlenden compileReleaseKotlin-Task und "Symbol nicht gefunden" fuehrte. file_picker >=12.0.0-beta.1 respektiert zusaetzlich die bereits vom Flutter-Template gesetzte Gradle-Property android.builtInKotlin=false und wendet das Plugin dann korrekt an - file_picker auf ">=12.0.0-beta.1 <13.0.0" (share_plus zurueck auf ^13.3.0, beide dann konsistent auf win32 ^6.x) gesetzt, um dies zu nutzen. Zusaetzlich beim manuellen Durchtesten auf dem Pixel_10a-Emulator einen zweiten, davon unabhaengigen Bug gefunden und behoben: CurrentMissionMeta- Notifier.loadMission()/restoreLastSession() aktualisierten zwar Wegpunkte und Missionsname, bewegten aber nie die Kartenkamera - eine geladene Mission wurde dadurch mit falscher Kartenausschnitt angezeigt (Name/ Wegpunkte einer Stadt, Karte noch an der zuletzt betrachteten Stelle). Fix: _fitMapToWaypoints() zentriert nach dem Laden auf die Bounding-Box der Mission, analog der bereits vorhandenen _onFitPressed()-Logik in MapSearchControls. Getestet: 13 neue Unit-Tests fuer die Sharing-Codecs, 4 neue Widget-Tests mit einem Fake-SharingService (kein echter Platform-Channel-Zugriff in Tests). Alle 97 Tests sowie flutter analyze bestehen. Manuell auf dem Pixel_10a-Emulator verifiziert: natives Share-Sheet oeffnet sich mit der korrekten JSON-Datei, Datei-Import ueber den Systemdialog legt eine neue Mission bzw. ein neues Drohnenprofil an, Kartensprung beim Laden einer Mission funktioniert jetzt korrekt fuer sowohl manuelles Laden als auch den Autosave-Restore beim App-Start. Co-Authored-By: Claude Sonnet 5 --- app/lib/app_info.dart | 4 + .../services/database/mission_repository.dart | 39 +---- .../database/waypoint_json_codec.dart | 36 ++++ .../services/sharing/drone_share_codec.dart | 113 +++++++++++++ .../services/sharing/mission_share_codec.dart | 109 ++++++++++++ app/lib/services/sharing/sharing_service.dart | 49 ++++++ .../ui/providers/mission_meta_provider.dart | 23 +++ app/lib/ui/providers/sharing_provider.dart | 5 + app/lib/ui/widgets/missions_drones_panel.dart | 148 +++++++++++++--- app/pubspec.lock | 64 +++++++ app/pubspec.yaml | 2 + .../sharing/drone_share_codec_test.dart | 53 ++++++ .../sharing/mission_share_codec_test.dart | 114 +++++++++++++ app/test/widget_test.dart | 160 ++++++++++++++++++ 14 files changed, 867 insertions(+), 52 deletions(-) create mode 100644 app/lib/app_info.dart create mode 100644 app/lib/services/database/waypoint_json_codec.dart create mode 100644 app/lib/services/sharing/drone_share_codec.dart create mode 100644 app/lib/services/sharing/mission_share_codec.dart create mode 100644 app/lib/services/sharing/sharing_service.dart create mode 100644 app/lib/ui/providers/sharing_provider.dart create mode 100644 app/test/services/sharing/drone_share_codec_test.dart create mode 100644 app/test/services/sharing/mission_share_codec_test.dart diff --git a/app/lib/app_info.dart b/app/lib/app_info.dart new file mode 100644 index 0000000..c88eae7 --- /dev/null +++ b/app/lib/app_info.dart @@ -0,0 +1,4 @@ +/// App-Version fuer den Export-Dateikopf beim Teilen von Missionen/ +/// Drohnenprofilen (Doku 3.6/3.5, analog APP_VERSION im HTML-Demonstrator) - +/// muss synchron zum `version:`-Feld in pubspec.yaml gehalten werden. +const kAppVersion = '1.0.0'; diff --git a/app/lib/services/database/mission_repository.dart b/app/lib/services/database/mission_repository.dart index 65c557b..dab81cc 100644 --- a/app/lib/services/database/mission_repository.dart +++ b/app/lib/services/database/mission_repository.dart @@ -4,6 +4,7 @@ import 'package:drift/drift.dart'; import '../../domain/waypoint/flat_waypoint_list.dart'; import 'app_database.dart'; +import 'waypoint_json_codec.dart'; /// Kopfzeilen-taugliche Zusammenfassung einer gespeicherten Mission, ohne /// die komplette Wegpunktliste zu materialisieren (fuer die Missionsliste @@ -28,11 +29,13 @@ class LoadedMission { const LoadedMission({ required this.name, required this.placeName, + required this.createdAt, required this.waypoints, }); final String name; final String? placeName; + final DateTime createdAt; final List waypoints; } @@ -70,8 +73,9 @@ class MissionRepository { return LoadedMission( name: row.name, placeName: row.location, + createdAt: row.createdAt, waypoints: [ - for (final entry in decoded) _waypointFromJson(entry as Map), + for (final entry in decoded) waypointFromJson(entry as Map), ], ); } @@ -85,7 +89,7 @@ class MissionRepository { String? placeName, required List waypoints, }) async { - final waypointsJson = jsonEncode([for (final w in waypoints) _waypointToJson(w)]); + final waypointsJson = jsonEncode([for (final w in waypoints) waypointToJson(w)]); final companion = MissionsCompanion( name: Value(name), location: Value(placeName), @@ -115,35 +119,4 @@ class MissionRepository { Future delete(int id) async { await (_db.delete(_db.missions)..where((m) => m.id.equals(id))).go(); } - - Map _waypointToJson(Waypoint w) => { - 'lat': w.lat, - 'lon': w.lon, - 'altitudeM': w.altitudeM, - 'speedMs': w.speedMs, - 'catchRadiusM': w.catchRadiusM, - 'action': w.action.name, - 'jumpTargetIndex': w.jumpTargetIndex, - 'jumpRepeatCount': w.jumpRepeatCount, - 'windSpeedMs': w.windSpeedMs, - 'windDirFromDeg': w.windDirFromDeg, - 'windElevationM': w.windElevationM, - }; - - Waypoint _waypointFromJson(Map json) => Waypoint( - lat: (json['lat'] as num).toDouble(), - lon: (json['lon'] as num).toDouble(), - altitudeM: (json['altitudeM'] as num).toDouble(), - speedMs: (json['speedMs'] as num).toDouble(), - catchRadiusM: (json['catchRadiusM'] as num).toDouble(), - action: WaypointAction.values.firstWhere( - (a) => a.name == json['action'], - orElse: () => WaypointAction.none, - ), - jumpTargetIndex: json['jumpTargetIndex'] as int?, - jumpRepeatCount: json['jumpRepeatCount'] as int?, - windSpeedMs: (json['windSpeedMs'] as num?)?.toDouble(), - windDirFromDeg: (json['windDirFromDeg'] as num?)?.toDouble(), - windElevationM: (json['windElevationM'] as num?)?.toDouble(), - ); } diff --git a/app/lib/services/database/waypoint_json_codec.dart b/app/lib/services/database/waypoint_json_codec.dart new file mode 100644 index 0000000..b176984 --- /dev/null +++ b/app/lib/services/database/waypoint_json_codec.dart @@ -0,0 +1,36 @@ +import '../../domain/waypoint/flat_waypoint_list.dart'; + +/// JSON-(De-)Serialisierung fuer [Waypoint] - gemeinsam genutzt von der +/// Missions-Persistenz ([MissionRepository]) und dem Mission-Sharing +/// ([MissionShareCodec]), damit das Datei-Feldformat an beiden Stellen +/// garantiert identisch bleibt. +Map waypointToJson(Waypoint w) => { + 'lat': w.lat, + 'lon': w.lon, + 'altitudeM': w.altitudeM, + 'speedMs': w.speedMs, + 'catchRadiusM': w.catchRadiusM, + 'action': w.action.name, + 'jumpTargetIndex': w.jumpTargetIndex, + 'jumpRepeatCount': w.jumpRepeatCount, + 'windSpeedMs': w.windSpeedMs, + 'windDirFromDeg': w.windDirFromDeg, + 'windElevationM': w.windElevationM, + }; + +Waypoint waypointFromJson(Map json) => Waypoint( + lat: (json['lat'] as num).toDouble(), + lon: (json['lon'] as num).toDouble(), + altitudeM: (json['altitudeM'] as num).toDouble(), + speedMs: (json['speedMs'] as num).toDouble(), + catchRadiusM: (json['catchRadiusM'] as num).toDouble(), + action: WaypointAction.values.firstWhere( + (a) => a.name == json['action'], + orElse: () => WaypointAction.none, + ), + jumpTargetIndex: json['jumpTargetIndex'] as int?, + jumpRepeatCount: json['jumpRepeatCount'] as int?, + windSpeedMs: (json['windSpeedMs'] as num?)?.toDouble(), + windDirFromDeg: (json['windDirFromDeg'] as num?)?.toDouble(), + windElevationM: (json['windElevationM'] as num?)?.toDouble(), + ); diff --git a/app/lib/services/sharing/drone_share_codec.dart b/app/lib/services/sharing/drone_share_codec.dart new file mode 100644 index 0000000..17f2674 --- /dev/null +++ b/app/lib/services/sharing/drone_share_codec.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; + +import '../../app_info.dart'; +import '../../domain/mission/drone_profile.dart'; + +/// Baut die Export-Datei fuer ein Drohnenprofil (Doku 3.5, analog +/// buildDroneExportObject()/shareDrone() im HTML-Demonstrator). Die id wird +/// bewusst nicht mitexportiert - beim Import entsteht immer ein neues, +/// eigenstaendiges Profil (siehe parseDroneImportJson()). +String buildDroneExportJson(DroneProfile profile) { + final map = { + 'appVersion': kAppVersion, + 'exportedAt': DateTime.now().toUtc().toIso8601String(), + 'drone': { + 'name': profile.name, + 'minSpeed': profile.minSpeed, + 'cruiseSpeed': profile.cruiseSpeed, + 'maxSpeed': profile.maxSpeed, + 'minTurnRadius': profile.minTurnRadius, + 'loiterRadius': profile.loiterRadius, + 'catchRadius': profile.catchRadius, + 'maxClimbRate': profile.maxClimbRate, + 'maxDescentRate': profile.maxDescentRate, + 'maxRangeM': profile.maxRangeM, + 'maxEnduranceMin': profile.maxEnduranceMin, + 'maxAltitudeM': profile.maxAltitudeM, + }, + }; + return const JsonEncoder.withIndent(' ').convert(map); +} + +/// Vorschlagsname fuer die geteilte Datei (Doku 3.5, analog +/// `'drone-' + safeName + '.json'` im HTML-Demonstrator). +String droneExportFilename(String name) { + final safeName = name.replaceAll(RegExp(r'[^a-zA-Z0-9]'), '_'); + return 'drone-$safeName.json'; +} + +class ParsedDroneImport { + const ParsedDroneImport({required this.profile, this.versionMismatch}); + + final DroneProfile profile; + + /// Enthaelt die appVersion der Datei, falls sie sich von [kAppVersion] in + /// der Hauptversionsnummer unterscheidet - sonst null. + final String? versionMismatch; +} + +const _requiredNumericFields = [ + 'minSpeed', + 'cruiseSpeed', + 'maxSpeed', + 'minTurnRadius', + 'loiterRadius', + 'catchRadius', + 'maxClimbRate', + 'maxDescentRate', + 'maxRangeM', + 'maxEnduranceMin', + 'maxAltitudeM', +]; + +/// Parst eine importierte Drohnenprofil-Datei - wirft [FormatException] bei +/// ungueltigem JSON oder fehlenden Pflichtfeldern (Doku 3.5, analog dem +/// `change`-Handler von importDroneInput im HTML-Demonstrator). +ParsedDroneImport parseDroneImportJson(String jsonText) { + final Object? decoded; + try { + decoded = jsonDecode(jsonText); + } on FormatException { + throw const FormatException('Invalid drone profile file'); + } + if (decoded is! Map) { + throw const FormatException('Invalid drone profile file'); + } + final droneData = decoded['drone'] ?? decoded; + if (droneData is! Map) { + throw const FormatException('Invalid drone profile file'); + } + + final name = droneData['name']; + if (name is! String || name.isEmpty) { + throw const FormatException('File is not a valid drone profile'); + } + for (final field in _requiredNumericFields) { + if (droneData[field] is! num) { + throw const FormatException('File is not a valid drone profile'); + } + } + + final profile = DroneProfile( + name: name, + minSpeed: (droneData['minSpeed'] as num).toDouble(), + cruiseSpeed: (droneData['cruiseSpeed'] as num).toDouble(), + maxSpeed: (droneData['maxSpeed'] as num).toDouble(), + minTurnRadius: (droneData['minTurnRadius'] as num).toDouble(), + loiterRadius: (droneData['loiterRadius'] as num).toDouble(), + catchRadius: (droneData['catchRadius'] as num).toDouble(), + maxClimbRate: (droneData['maxClimbRate'] as num).toDouble(), + maxDescentRate: (droneData['maxDescentRate'] as num).toDouble(), + maxRangeM: (droneData['maxRangeM'] as num).toDouble(), + maxEnduranceMin: (droneData['maxEnduranceMin'] as num).toDouble(), + maxAltitudeM: (droneData['maxAltitudeM'] as num).toDouble(), + ); + + final appVersion = decoded['appVersion'] as String?; + final mismatch = appVersion != null && + appVersion.split('.').first != kAppVersion.split('.').first + ? appVersion + : null; + + return ParsedDroneImport(profile: profile, versionMismatch: mismatch); +} diff --git a/app/lib/services/sharing/mission_share_codec.dart b/app/lib/services/sharing/mission_share_codec.dart new file mode 100644 index 0000000..d6b82cc --- /dev/null +++ b/app/lib/services/sharing/mission_share_codec.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; + +import '../../app_info.dart'; +import '../../domain/waypoint/flat_waypoint_list.dart'; +import '../database/waypoint_json_codec.dart'; + +/// Missionsdaten fuer den Export - unabhaengig von der Drift-Zeilenklasse, +/// damit dieser Codec ohne Datenbankzugriff testbar bleibt. +class MissionExportData { + const MissionExportData({ + required this.name, + this.placeName, + required this.createdAt, + required this.waypoints, + }); + + final String name; + final String? placeName; + final DateTime createdAt; + final List waypoints; +} + +/// Baut die Export-Datei fuer eine Mission (Doku 3.6, analog +/// buildMissionExportObject()/shareMission() im HTML-Demonstrator) - ein +/// mit `appVersion`/`exportedAt` umschlossenes JSON-Dokument, damit ein +/// Import spaeter erkennen kann, aus welcher App-Version die Datei stammt. +String buildMissionExportJson(MissionExportData mission) { + final map = { + 'appVersion': kAppVersion, + 'exportedAt': DateTime.now().toUtc().toIso8601String(), + 'mission': { + 'name': mission.name, + 'placeName': mission.placeName, + 'createdAt': mission.createdAt.toUtc().toIso8601String(), + 'waypoints': [for (final w in mission.waypoints) waypointToJson(w)], + }, + }; + return const JsonEncoder.withIndent(' ').convert(map); +} + +/// Vorschlagsname fuer die geteilte Datei (Doku 3.6, analog +/// `'mission-' + safeName + '.json'` im HTML-Demonstrator). +String missionExportFilename(String label) { + final safeName = label.replaceAll(RegExp(r'[^a-zA-Z0-9]'), '_'); + return 'mission-$safeName.json'; +} + +class ParsedMissionImport { + const ParsedMissionImport({ + required this.name, + this.placeName, + required this.waypoints, + this.versionMismatch, + }); + + final String name; + final String? placeName; + final List waypoints; + + /// Enthaelt die appVersion der Datei, falls sie sich von [kAppVersion] in + /// der Hauptversionsnummer unterscheidet - sonst null (analog der + /// "Imported from a different app version"-Warnung im HTML-Demonstrator). + final String? versionMismatch; +} + +/// Parst eine importierte Missionsdatei - wirft [FormatException] bei +/// ungueltigem JSON oder fehlenden Wegpunkten (Doku 3.6, analog dem +/// `change`-Handler von importMissionInput im HTML-Demonstrator). +ParsedMissionImport parseMissionImportJson(String jsonText) { + final Object? decoded; + try { + decoded = jsonDecode(jsonText); + } on FormatException { + throw const FormatException('Invalid mission file'); + } + if (decoded is! Map) { + throw const FormatException('Invalid mission file'); + } + final missionData = decoded['mission'] ?? decoded; + if (missionData is! Map) { + throw const FormatException('Invalid mission file'); + } + final waypointsRaw = missionData['waypoints']; + if (waypointsRaw is! List || waypointsRaw.isEmpty) { + throw const FormatException('File has no waypoints'); + } + + final waypoints = [ + for (final w in waypointsRaw) waypointFromJson(w as Map), + ]; + final name = (missionData['name'] as String?) ?? + (missionData['placeName'] as String?) ?? + 'Imported mission'; + final placeName = + (missionData['placeName'] as String?) ?? (missionData['name'] as String?); + + final appVersion = decoded['appVersion'] as String?; + final mismatch = appVersion != null && + appVersion.split('.').first != kAppVersion.split('.').first + ? appVersion + : null; + + return ParsedMissionImport( + name: name, + placeName: placeName, + waypoints: waypoints, + versionMismatch: mismatch, + ); +} diff --git a/app/lib/services/sharing/sharing_service.dart b/app/lib/services/sharing/sharing_service.dart new file mode 100644 index 0000000..58255c8 --- /dev/null +++ b/app/lib/services/sharing/sharing_service.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +/// Kapselt die plattformseitige Ein-/Ausgabe fuer das Teilen/Importieren von +/// Missionen und Drohnenprofilen (Doku 3.5/3.6): schreibt eine JSON-Datei in +/// ein temporaeres Verzeichnis und oeffnet das native Share-Sheet (analog +/// navigator.share() im HTML-Demonstrator), bzw. liest eine vom Nutzer +/// ausgewaehlte JSON-Datei ein (analog dem versteckten ``). +/// +/// Bewusst als Klasse mit Instanzmethoden (nicht als statische Aufrufe der +/// Plugins direkt) gehalten, damit sie sich ueber einen Riverpod-Provider +/// durch einen Test-Doppelgaenger ersetzen laesst, ohne echte Platform- +/// Channels anzusprechen. +class SharingService { + const SharingService(); + + /// Schreibt [json] in eine temporaere Datei namens [filename] und oeffnet + /// das native Share-Sheet dafuer. Es gibt bewusst keinen Blob-/Download- + /// Fallback wie im HTML-Demonstrator - auf Android ist `share_plus` + /// immer verfuegbar. + Future shareJsonFile({ + required String json, + required String filename, + required String shareTitle, + }) async { + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/$filename'); + await file.writeAsString(json); + await SharePlus.instance.share( + ShareParams(files: [XFile(file.path)], title: shareTitle), + ); + } + + /// Oeffnet den nativen Dateiauswahl-Dialog fuer eine `.json`-Datei und + /// gibt deren Textinhalt zurueck - null, wenn der Nutzer abgebrochen hat. + Future pickJsonFileText() async { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + ); + if (result == null || result.files.isEmpty) return null; + final bytes = await result.files.first.readAsBytes(); + return utf8.decode(bytes); + } +} diff --git a/app/lib/ui/providers/mission_meta_provider.dart b/app/lib/ui/providers/mission_meta_provider.dart index c0f8d55..cc5c534 100644 --- a/app/lib/ui/providers/mission_meta_provider.dart +++ b/app/lib/ui/providers/mission_meta_provider.dart @@ -1,9 +1,14 @@ import 'dart:async'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:latlong2/latlong.dart'; +import '../../domain/waypoint/flat_waypoint_list.dart'; import 'current_mission_provider.dart'; import 'database_provider.dart'; +import 'map_controller_provider.dart'; /// Metadaten der aktuell in Bearbeitung befindlichen Mission - ergaenzt /// currentMissionProvider (das nur die Wegpunkte haelt) um Name/Ort und die @@ -55,6 +60,7 @@ class CurrentMissionMetaNotifier extends Notifier { ref.read(currentMissionProvider.notifier).replaceAll(loaded.waypoints); _suppressAutosave = false; state = MissionMeta(id: missionId, name: loaded.name, placeName: loaded.placeName); + _fitMapToWaypoints(loaded.waypoints); } /// Analog startNewMission() im HTML-Demonstrator: sichert zuerst noch @@ -85,9 +91,26 @@ class CurrentMissionMetaNotifier extends Notifier { ref.read(currentMissionProvider.notifier).replaceAll(loaded.waypoints); _suppressAutosave = false; state = MissionMeta(id: id, name: loaded.name, placeName: loaded.placeName); + _fitMapToWaypoints(loaded.waypoints); await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(id); } + /// Zentriert die Karte auf die geladene Mission (Doku 3.6) - ohne dies + /// bliebe die Kamera an der zuletzt betrachteten Stelle stehen, waehrend + /// eine voellig andernorts gelegene Mission angezeigt wird. Analog + /// fitToWaypoints() im HTML-Demonstrator bzw. _onFitPressed() in + /// MapSearchControls, hier aber automatisch statt nutzerausgeloest. + void _fitMapToWaypoints(List waypoints) { + if (waypoints.isEmpty) return; + final points = [for (final w in waypoints) LatLng(w.lat, w.lon)]; + ref.read(mapControllerProvider).fitCamera( + CameraFit.bounds( + bounds: LatLngBounds.fromPoints(points), + padding: const EdgeInsets.fromLTRB(50, 50, 50, 140), + ), + ); + } + /// Benennt eine gespeicherte Mission um (Doku 3.6: nur ueber die /// Missionsliste moeglich, dort hat jede Zeile immer bereits eine id). /// Aktualisiert zusaetzlich den eigenen Zustand, falls [id] die gerade diff --git a/app/lib/ui/providers/sharing_provider.dart b/app/lib/ui/providers/sharing_provider.dart new file mode 100644 index 0000000..07708b0 --- /dev/null +++ b/app/lib/ui/providers/sharing_provider.dart @@ -0,0 +1,5 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../services/sharing/sharing_service.dart'; + +final sharingServiceProvider = Provider((ref) => const SharingService()); diff --git a/app/lib/ui/widgets/missions_drones_panel.dart b/app/lib/ui/widgets/missions_drones_panel.dart index c68612a..b4171f9 100644 --- a/app/lib/ui/widgets/missions_drones_panel.dart +++ b/app/lib/ui/widgets/missions_drones_panel.dart @@ -3,9 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../domain/mission/drone_profile.dart'; import '../../services/database/mission_repository.dart'; +import '../../services/sharing/drone_share_codec.dart'; +import '../../services/sharing/mission_share_codec.dart'; import '../providers/active_drone_profile_provider.dart'; import '../providers/database_provider.dart'; import '../providers/mission_meta_provider.dart'; +import '../providers/sharing_provider.dart'; import '../providers/wind_provider.dart'; import 'drone_profile_editor.dart'; @@ -114,16 +117,23 @@ class _MissionsDronesPanelState extends ConsumerState { children: [ Padding( padding: const EdgeInsets.all(12), - child: Align( - alignment: Alignment.centerLeft, - child: _ToolbarButton( - icon: Icons.add, - label: 'New mission', - onTap: () async { - await ref.read(currentMissionMetaProvider.notifier).startNew(); - if (mounted) Navigator.of(context).pop(); - }, - ), + child: Row( + children: [ + _ToolbarButton( + icon: Icons.add, + label: 'New mission', + onTap: () async { + await ref.read(currentMissionMetaProvider.notifier).startNew(); + if (mounted) Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 8), + _ToolbarButton( + icon: Icons.file_upload_outlined, + label: 'Import', + onTap: _importMission, + ), + ], ), ), Expanded( @@ -192,6 +202,10 @@ class _MissionsDronesPanelState extends ConsumerState { onPressed: () => _renameMission(m), icon: const Icon(Icons.edit, color: Colors.white70, size: 18), ), + IconButton( + onPressed: () => _shareMission(m), + icon: const Icon(Icons.share, color: Color(0xFF4D9DFF), size: 18), + ), IconButton( onPressed: () => ref.read(missionRepositoryProvider).delete(m.id), icon: const Icon(Icons.delete_outline, @@ -203,6 +217,55 @@ class _MissionsDronesPanelState extends ConsumerState { ); } + /// Teilt eine gespeicherte Mission als JSON-Datei ueber das native + /// Share-Sheet (Doku 3.6, analog shareMission() im HTML-Demonstrator) - + /// laedt zuerst die vollstaendige Wegpunktliste nach, da [MissionSummary] + /// aus Speichergruenden nur die Anzahl fuehrt. + Future _shareMission(MissionSummary m) async { + final full = await ref.read(missionRepositoryProvider).load(m.id); + if (full == null) return; + final json = buildMissionExportJson(MissionExportData( + name: full.name, + placeName: full.placeName, + createdAt: full.createdAt, + waypoints: full.waypoints, + )); + await ref.read(sharingServiceProvider).shareJsonFile( + json: json, + filename: missionExportFilename(full.placeName ?? full.name), + shareTitle: 'Mission: ${full.placeName ?? full.name}', + ); + } + + /// Importiert eine zuvor geteilte Missionsdatei als neue, eigenstaendige + /// Mission (Doku 3.6, analog dem `change`-Handler von importMissionInput + /// im HTML-Demonstrator) - ueberschreibt nie eine bestehende Mission. + Future _importMission() async { + final messenger = ScaffoldMessenger.of(context); + final text = await ref.read(sharingServiceProvider).pickJsonFileText(); + if (text == null) return; + final ParsedMissionImport parsed; + try { + parsed = parseMissionImportJson(text); + } on FormatException catch (e) { + messenger.showSnackBar(SnackBar(content: Text(e.message))); + return; + } + await ref.read(missionRepositoryProvider).upsert( + name: parsed.name, + placeName: parsed.placeName, + waypoints: parsed.waypoints, + ); + if (parsed.versionMismatch != null) { + messenger.showSnackBar(SnackBar( + content: Text( + 'Imported from a different app version (${parsed.versionMismatch}) – check carefully'), + )); + } else { + messenger.showSnackBar(const SnackBar(content: Text('Mission imported'))); + } + } + Future _renameMission(MissionSummary m) async { final controller = TextEditingController(text: m.name); final newName = await showDialog( @@ -244,15 +307,22 @@ class _MissionsDronesPanelState extends ConsumerState { children: [ Padding( padding: const EdgeInsets.all(12), - child: Align( - alignment: Alignment.centerLeft, - child: _ToolbarButton( - icon: Icons.add, - label: 'New profile', - onTap: () => Navigator.of(context).push(MaterialPageRoute( - builder: (_) => const DroneProfileEditor(), - )), - ), + child: Row( + children: [ + _ToolbarButton( + icon: Icons.add, + label: 'New profile', + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => const DroneProfileEditor(), + )), + ), + const SizedBox(width: 8), + _ToolbarButton( + icon: Icons.file_upload_outlined, + label: 'Import', + onTap: _importDrone, + ), + ], ), ), Expanded( @@ -312,6 +382,10 @@ class _MissionsDronesPanelState extends ConsumerState { )), icon: const Icon(Icons.edit, color: Colors.white70, size: 18), ), + IconButton( + onPressed: () => _shareDrone(d), + icon: const Icon(Icons.share, color: Color(0xFF4D9DFF), size: 18), + ), IconButton( onPressed: () => _deleteDrone(d, all), icon: const Icon(Icons.delete_outline, @@ -337,6 +411,42 @@ class _MissionsDronesPanelState extends ConsumerState { await ref.read(activeDroneProfileProvider.notifier).fallBackTo(remaining.first); } } + + /// Teilt ein Drohnenprofil als JSON-Datei ueber das native Share-Sheet + /// (Doku 3.5, analog shareDrone() im HTML-Demonstrator). + Future _shareDrone(DroneProfile d) async { + final json = buildDroneExportJson(d); + await ref.read(sharingServiceProvider).shareJsonFile( + json: json, + filename: droneExportFilename(d.name), + shareTitle: 'Drone profile: ${d.name}', + ); + } + + /// Importiert ein zuvor geteiltes Drohnenprofil als neues, eigenstaendiges + /// Profil (Doku 3.5, analog dem `change`-Handler von importDroneInput im + /// HTML-Demonstrator) - ueberschreibt nie ein bestehendes Profil. + Future _importDrone() async { + final messenger = ScaffoldMessenger.of(context); + final text = await ref.read(sharingServiceProvider).pickJsonFileText(); + if (text == null) return; + final ParsedDroneImport parsed; + try { + parsed = parseDroneImportJson(text); + } on FormatException catch (e) { + messenger.showSnackBar(SnackBar(content: Text(e.message))); + return; + } + await ref.read(droneProfileRepositoryProvider).insert(parsed.profile); + if (parsed.versionMismatch != null) { + messenger.showSnackBar(SnackBar( + content: Text( + 'Imported from a different app version (${parsed.versionMismatch}) – check carefully'), + )); + } else { + messenger.showSnackBar(const SnackBar(content: Text('Drone profile imported'))); + } + } } class _ToolbarButton extends StatelessWidget { diff --git a/app/pubspec.lock b/app/pubspec.lock index a040fca..8a07021 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -185,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" crypto: dependency: transitive description: @@ -225,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" + url: "https://pub.dev" + source: hosted + version: "0.7.13" drift: dependency: "direct main" description: @@ -257,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -265,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: fdc6a37f715d19f35b131decf1ce39242eeed5ddae18c0818c3eccb731ab76be + url: "https://pub.dev" + source: hosted + version: "12.0.0-beta.7" fixnum: dependency: transitive description: @@ -310,6 +342,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.3.1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_riverpod: dependency: "direct main" description: @@ -728,6 +768,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + 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" shelf: dependency: transitive description: @@ -1045,6 +1101,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + url: "https://pub.dev" + source: hosted + version: "6.3.0" wkt_parser: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 4da2e70..1ae01d2 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -51,6 +51,8 @@ dependencies: flutter_riverpod: ^3.4.1 go_router: ^17.3.0 url_launcher: ^6.3.2 + share_plus: ^13.3.0 + file_picker: ">=12.0.0-beta.1 <13.0.0" dev_dependencies: flutter_test: diff --git a/app/test/services/sharing/drone_share_codec_test.dart b/app/test/services/sharing/drone_share_codec_test.dart new file mode 100644 index 0000000..e003d87 --- /dev/null +++ b/app/test/services/sharing/drone_share_codec_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/domain/mission/drone_profile.dart'; +import 'package:dmc_app/services/sharing/drone_share_codec.dart'; + +void main() { + final profile = DroneProfile.t1Ranger().copyWith(id: 7, name: 'T1 Ranger #2'); + + group('buildDroneExportJson / parseDroneImportJson', () { + test('round-trip erhaelt alle Flugleistungsfelder, aber nicht die id', () { + final json = buildDroneExportJson(profile); + final parsed = parseDroneImportJson(json); + + expect(parsed.profile.id, isNull); + expect(parsed.profile.name, 'T1 Ranger #2'); + expect(parsed.profile.minSpeed, profile.minSpeed); + expect(parsed.profile.maxSpeed, profile.maxSpeed); + expect(parsed.profile.maxAltitudeM, profile.maxAltitudeM); + expect(parsed.versionMismatch, isNull); + }); + + test('erkennt eine abweichende Hauptversion als Mismatch', () { + final json = buildDroneExportJson(profile).replaceFirst('"1.0.0"', '"2.0.0"'); + final parsed = parseDroneImportJson(json); + expect(parsed.versionMismatch, '2.0.0'); + }); + + test('wirft FormatException bei ungueltigem JSON', () { + expect(() => parseDroneImportJson('{not json'), throwsFormatException); + }); + + test('wirft FormatException wenn ein Pflichtfeld fehlt', () { + expect( + () => parseDroneImportJson('{"drone": {"name": "Incomplete"}}'), + throwsFormatException, + ); + }); + + test('wirft FormatException wenn der Name fehlt', () { + final json = buildDroneExportJson(profile).replaceFirst( + '"name": "T1 Ranger #2"', + '"name": ""', + ); + expect(() => parseDroneImportJson(json), throwsFormatException); + }); + }); + + group('droneExportFilename', () { + test('ersetzt Sonderzeichen durch Unterstriche', () { + expect(droneExportFilename('T1 Ranger #2'), 'drone-T1_Ranger__2.json'); + }); + }); +} diff --git a/app/test/services/sharing/mission_share_codec_test.dart b/app/test/services/sharing/mission_share_codec_test.dart new file mode 100644 index 0000000..ceb5b00 --- /dev/null +++ b/app/test/services/sharing/mission_share_codec_test.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; +import 'package:dmc_app/services/sharing/mission_share_codec.dart'; + +void main() { + const waypoints = [ + Waypoint( + lat: 48.1351, + lon: 11.5820, + altitudeM: 60, + speedMs: 15, + catchRadiusM: 60, + windSpeedMs: 4, + windDirFromDeg: 270, + windElevationM: 520, + ), + Waypoint( + lat: 48.1360, + lon: 11.5830, + altitudeM: 120, + speedMs: 20, + catchRadiusM: 60, + action: WaypointAction.loiter, + ), + ]; + + group('buildMissionExportJson / parseMissionImportJson', () { + test('round-trip erhaelt Name, Ort und alle Wegpunktfelder', () { + final json = buildMissionExportJson(MissionExportData( + name: 'Munich Loop', + placeName: 'Munich', + createdAt: DateTime.utc(2026, 1, 1), + waypoints: waypoints, + )); + + final parsed = parseMissionImportJson(json); + + expect(parsed.name, 'Munich Loop'); + expect(parsed.placeName, 'Munich'); + expect(parsed.versionMismatch, isNull); + expect(parsed.waypoints, hasLength(2)); + expect(parsed.waypoints[0].lat, waypoints[0].lat); + expect(parsed.waypoints[0].windSpeedMs, 4); + expect(parsed.waypoints[1].action, WaypointAction.loiter); + }); + + test('fehlende appVersion-Uebereinstimmung wird als Mismatch erkannt', () { + final json = buildMissionExportJson(MissionExportData( + name: 'Old export', + placeName: null, + createdAt: DateTime.utc(2020, 1, 1), + waypoints: waypoints, + )).replaceFirst('"1.0.0"', '"2.4.1"'); + + final parsed = parseMissionImportJson(json); + + expect(parsed.versionMismatch, '2.4.1'); + }); + + test('Datei ohne "appVersion"-Umschlag (nur das rohe mission-Objekt) wird akzeptiert', () { + final rawMission = { + 'name': 'Bare mission', + 'waypoints': [ + { + 'lat': 1.0, + 'lon': 2.0, + 'altitudeM': 60.0, + 'speedMs': 15.0, + 'catchRadiusM': 60.0, + 'action': 'none', + }, + ], + }; + final parsed = parseMissionImportJson(jsonEncode(rawMission)); + expect(parsed.name, 'Bare mission'); + expect(parsed.waypoints, hasLength(1)); + }); + + test('wirft FormatException bei ungueltigem JSON', () { + expect(() => parseMissionImportJson('not json'), throwsFormatException); + }); + + test('wirft FormatException wenn Wegpunkte fehlen', () { + expect( + () => parseMissionImportJson('{"mission": {"name": "Empty"}}'), + throwsFormatException, + ); + }); + + test('fehlender Name faellt auf placeName, dann auf Standardtext zurueck', () { + final withPlaceOnly = parseMissionImportJson( + '{"mission": {"placeName": "Somewhere", "waypoints": [{"lat":1,"lon":1,' + '"altitudeM":60,"speedMs":15,"catchRadiusM":60,"action":"none"}]}}', + ); + expect(withPlaceOnly.name, 'Somewhere'); + + final withNeither = parseMissionImportJson( + '{"mission": {"waypoints": [{"lat":1,"lon":1,' + '"altitudeM":60,"speedMs":15,"catchRadiusM":60,"action":"none"}]}}', + ); + expect(withNeither.name, 'Imported mission'); + }); + }); + + group('missionExportFilename', () { + test('ersetzt Sonderzeichen durch Unterstriche', () { + expect(missionExportFilename('Sankt Pölten / Nord'), + 'mission-Sankt_P_lten___Nord.json'); + }); + }); +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 6961633..1eec8a7 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -10,25 +10,56 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:dmc_app/app_mode/app_mode_cubit.dart'; +import 'package:dmc_app/domain/mission/drone_profile.dart'; import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; import 'package:dmc_app/services/database/app_database.dart' hide DroneProfile, Mission; import 'package:dmc_app/services/geocoding/nominatim_service.dart'; +import 'package:dmc_app/services/sharing/drone_share_codec.dart'; +import 'package:dmc_app/services/sharing/mission_share_codec.dart'; +import 'package:dmc_app/services/sharing/sharing_service.dart'; import 'package:dmc_app/ui/app_shell.dart'; import 'package:dmc_app/ui/providers/current_mission_provider.dart'; import 'package:dmc_app/ui/providers/database_provider.dart'; import 'package:dmc_app/ui/providers/geocoding_provider.dart'; import 'package:dmc_app/ui/providers/mission_meta_provider.dart'; +import 'package:dmc_app/ui/providers/sharing_provider.dart'; import 'package:dmc_app/ui/widgets/full_value_chart.dart'; import 'package:dmc_app/ui/widgets/halo_menu.dart'; import 'package:dmc_app/ui/widgets/header_wind_pill.dart'; import 'package:dmc_app/ui/widgets/map_search_controls.dart'; import 'package:dmc_app/ui/providers/map_controller_provider.dart'; +import 'package:dmc_app/ui/widgets/missions_drones_panel.dart'; import 'package:dmc_app/ui/widgets/reticle_button.dart'; import 'package:dmc_app/ui/widgets/waypoint_chip.dart'; import 'package:dmc_app/ui/widgets/waypoint_list_panel.dart'; import 'package:dmc_app/ui/widgets/wind_marker_pill.dart'; import 'package:latlong2/latlong.dart'; +/// Test-Doppelgaenger fuer [SharingService]: zeichnet auf, was geteilt +/// wurde, statt echte Platform-Channels (share_plus/file_picker) +/// anzusprechen, und liefert einen vorab festgelegten Datei-Inhalt fuer +/// den Import zurueck. +class _FakeSharingService extends SharingService { + _FakeSharingService({this.pickResult}); + + final String? pickResult; + String? sharedJson; + String? sharedFilename; + + @override + Future shareJsonFile({ + required String json, + required String filename, + required String shareTitle, + }) async { + sharedJson = json; + sharedFilename = filename; + } + + @override + Future pickJsonFileText() async => pickResult; +} + // AppShell/PlanScreen initialisieren beim Start die Drift-Datenbank (Doku // 4.15/7.5: Drohnenprofil-Bootstrap, Autosave-Restore) - in Widget-Tests // durch eine In-Memory-Instanz ersetzt, damit dafuer nicht das @@ -628,4 +659,133 @@ void main() { expect(find.byType(WindMarkerPill), findsOneWidget); }); + + testWidgets( + 'Import-Button im Missionen-Tab legt eine neue Mission aus der Datei an', + (tester) async { + final exportJson = buildMissionExportJson(MissionExportData( + name: 'Imported route', + placeName: 'Imported route', + createdAt: DateTime.utc(2026, 1, 1), + waypoints: const [ + Waypoint(lat: 1, lon: 1, altitudeM: 60, speedMs: 15, catchRadiusM: 60), + Waypoint(lat: 1, lon: 1.01, altitudeM: 60, speedMs: 15, catchRadiusM: 60), + ], + )); + final fakeSharing = _FakeSharingService(pickResult: exportJson); + final container = ProviderContainer( + overrides: [ + appDatabaseProvider.overrideWithValue(_testDatabase()), + sharingServiceProvider.overrideWithValue(fakeSharing), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: const MaterialApp(home: Scaffold(body: MissionsDronesPanel.missions())), + )); + await tester.pump(); + + expect(find.text('No saved missions yet'), findsOneWidget); + + await tester.tap(find.text('Import')); + await tester.pump(); + await tester.pump(); + + expect(find.text('Imported route'), findsOneWidget); + expect(find.text('Mission imported'), findsOneWidget); + }); + + testWidgets('Share-Icon einer Mission uebergibt den vollstaendigen Export an den SharingService', + (tester) async { + final fakeSharing = _FakeSharingService(); + final container = ProviderContainer( + overrides: [ + appDatabaseProvider.overrideWithValue(_testDatabase()), + sharingServiceProvider.overrideWithValue(fakeSharing), + ], + ); + addTearDown(container.dispose); + await container.read(missionRepositoryProvider).upsert( + name: 'Shared route', + placeName: 'Shared route', + waypoints: const [ + Waypoint(lat: 2, lon: 2, altitudeM: 60, speedMs: 15, catchRadiusM: 60), + Waypoint(lat: 2, lon: 2.01, altitudeM: 60, speedMs: 15, catchRadiusM: 60), + ], + ); + + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: const MaterialApp(home: Scaffold(body: MissionsDronesPanel.missions())), + )); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.share)); + await tester.pump(); + await tester.pump(); + + expect(fakeSharing.sharedFilename, 'mission-Shared_route.json'); + final parsed = parseMissionImportJson(fakeSharing.sharedJson!); + expect(parsed.name, 'Shared route'); + expect(parsed.waypoints, hasLength(2)); + }); + + testWidgets( + 'Import-Button im Drohnen-Tab legt ein neues Profil aus der Datei an', + (tester) async { + final exportJson = + buildDroneExportJson(DroneProfile.t1Ranger().copyWith(name: 'Imported drone')); + final fakeSharing = _FakeSharingService(pickResult: exportJson); + final container = ProviderContainer( + overrides: [ + appDatabaseProvider.overrideWithValue(_testDatabase()), + sharingServiceProvider.overrideWithValue(fakeSharing), + ], + ); + addTearDown(container.dispose); + await container.read(droneProfileRepositoryProvider).ensureSeeded(); + + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: const MaterialApp(home: Scaffold(body: MissionsDronesPanel.drones())), + )); + await tester.pump(); + + await tester.tap(find.text('Import')); + await tester.pump(); + await tester.pump(); + + expect(find.text('Imported drone'), findsOneWidget); + expect(find.text('Drone profile imported'), findsOneWidget); + }); + + testWidgets( + 'Share-Icon eines Drohnenprofils uebergibt den Export an den SharingService', + (tester) async { + final fakeSharing = _FakeSharingService(); + final container = ProviderContainer( + overrides: [ + appDatabaseProvider.overrideWithValue(_testDatabase()), + sharingServiceProvider.overrideWithValue(fakeSharing), + ], + ); + addTearDown(container.dispose); + await container.read(droneProfileRepositoryProvider).ensureSeeded(); + + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: const MaterialApp(home: Scaffold(body: MissionsDronesPanel.drones())), + )); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.share)); + await tester.pump(); + await tester.pump(); + + expect(fakeSharing.sharedFilename, 'drone-T1_Ranger.json'); + final parsed = parseDroneImportJson(fakeSharing.sharedJson!); + expect(parsed.profile.name, 'T1 Ranger'); + }); }