Files
dmc/app/lib/ui/widgets/missions_drones_panel.dart
T
Constantin LeueandClaude Sonnet 5 b51c6c16d5 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 <noreply@anthropic.com>
2026-07-28 19:50:19 +02:00

484 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
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';
enum _ManagementTab { missions, drones }
/// Vollbild-Verwaltungsmenue fuer Missionen und Drohnenprofile (Doku 3.5/
/// 3.6/7.1/7.3), per Tabs umschaltbar - analog #missionsPanel im HTML-
/// Demonstrator. Erreichbar ueber die Mission-/Drone-Chips in der
/// Fusszeile, die jeweils mit dem passenden Starttab oeffnen.
class MissionsDronesPanel extends ConsumerStatefulWidget {
const MissionsDronesPanel.missions({super.key}) : _initialTab = _ManagementTab.missions;
const MissionsDronesPanel.drones({super.key}) : _initialTab = _ManagementTab.drones;
final _ManagementTab _initialTab;
@override
ConsumerState<MissionsDronesPanel> createState() => _MissionsDronesPanelState();
}
class _MissionsDronesPanelState extends ConsumerState<MissionsDronesPanel> {
late _ManagementTab _tab = widget._initialTab;
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xEB080808),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
child: Row(
children: [
const Expanded(
child: Text(
'Missions & Drones',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, color: Colors.white),
),
],
),
),
Row(
children: [
_tabButton('Missions', _ManagementTab.missions),
_tabButton('Drones', _ManagementTab.drones),
],
),
const Divider(color: Colors.white24, height: 1),
Expanded(
child: switch (_tab) {
_ManagementTab.missions => _missionsTab(),
_ManagementTab.drones => _dronesTab(),
},
),
],
),
),
);
}
Widget _tabButton(String label, _ManagementTab tab) {
final active = _tab == tab;
return Expanded(
child: InkWell(
onTap: () => setState(() => _tab = tab),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: active ? const Color(0xFF4D9DFF) : Colors.transparent,
width: 2,
),
),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
color: active ? Colors.white : Colors.white60,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
),
);
}
// --- Missions ---
Widget _missionsTab() {
final summaries = ref.watch(missionSummariesProvider);
final currentId = ref.watch(currentMissionMetaProvider).id;
return Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
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(
child: summaries.when(
data: (missions) {
if (missions.isEmpty) {
return const Center(
child: Text('No saved missions yet',
style: TextStyle(color: Colors.white60, fontSize: 15)),
);
}
final sorted = [...missions]
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: sorted.length,
itemBuilder: (context, index) =>
_missionRow(sorted[index], isActive: sorted[index].id == currentId),
);
},
loading: () => const Center(
child: CircularProgressIndicator(color: Color(0xFF4D9DFF)),
),
error: (err, st) => Center(
child: Text('Error loading missions: $err',
style: const TextStyle(color: Color(0xFFE2574A))),
),
),
),
],
);
}
Widget _missionRow(MissionSummary m, {required bool isActive}) {
return GestureDetector(
onTap: () async {
await ref.read(currentMissionMetaProvider.notifier).loadMission(m.id);
await ref.read(headerWindProvider.notifier).refresh();
if (mounted) Navigator.of(context).pop();
},
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isActive
? const Color(0xFF4D9DFF).withValues(alpha: 0.28)
: Colors.white.withValues(alpha: 0.04),
border: isActive ? Border.all(color: const Color(0xFF4D9DFF)) : null,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(m.name, style: const TextStyle(color: Colors.white)),
),
Expanded(
child: Text('${m.waypointCount} wp',
style: const TextStyle(color: Colors.white70)),
),
Expanded(
child: Text(_formatDate(m.updatedAt),
style: const TextStyle(color: Colors.white70)),
),
IconButton(
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,
color: Color(0xFFE2574A), size: 18),
),
],
),
),
);
}
/// 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<void> _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<void> _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<void> _renameMission(MissionSummary m) async {
final controller = TextEditingController(text: m.name);
final newName = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A1A1A),
title: const Text('Rename mission', style: TextStyle(color: Colors.white)),
content: TextField(
controller: controller,
autofocus: true,
style: const TextStyle(color: Colors.white),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
child: const Text('Rename'),
),
],
),
);
if (newName == null || newName.isEmpty) return;
await ref.read(currentMissionMetaProvider.notifier).renameMission(m.id, newName);
}
String _formatDate(DateTime dt) =>
'${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
// --- Drones ---
Widget _dronesTab() {
final profiles = ref.watch(droneProfilesProvider);
final activeId = ref.watch(activeDroneProfileProvider).id;
return Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
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(
child: profiles.when(
data: (drones) => ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: drones.length,
itemBuilder: (context, index) =>
_droneRow(drones[index], drones, isActive: drones[index].id == activeId),
),
loading: () => const Center(
child: CircularProgressIndicator(color: Color(0xFF4D9DFF)),
),
error: (err, st) => Center(
child: Text('Error loading drones: $err',
style: const TextStyle(color: Color(0xFFE2574A))),
),
),
),
],
);
}
Widget _droneRow(DroneProfile d, List<DroneProfile> all, {required bool isActive}) {
return GestureDetector(
onTap: () async {
await ref.read(activeDroneProfileProvider.notifier).select(d);
if (mounted) Navigator.of(context).pop();
},
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isActive
? const Color(0xFF4D9DFF).withValues(alpha: 0.28)
: Colors.white.withValues(alpha: 0.04),
border: isActive ? Border.all(color: const Color(0xFF4D9DFF)) : null,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(d.name,
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.w600)),
Text(d.summary,
style: const TextStyle(color: Colors.white60, fontSize: 12)),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => DroneProfileEditor(existing: d),
)),
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,
color: Color(0xFFE2574A), size: 18),
),
],
),
),
);
}
Future<void> _deleteDrone(DroneProfile d, List<DroneProfile> all) async {
if (all.length <= 1) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('At least one drone profile is required')),
);
return;
}
final wasActive = ref.read(activeDroneProfileProvider).id == d.id;
await ref.read(droneProfileRepositoryProvider).delete(d.id!);
if (wasActive) {
final remaining = all.where((p) => p.id != d.id).toList();
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<void> _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<void> _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 {
const _ToolbarButton({required this.icon, required this.label, required this.onTap});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFF4D9DFF).withValues(alpha: 0.2),
border: Border.all(color: const Color(0xFF4D9DFF).withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: const Color(0xFFCFE2FF)),
const SizedBox(width: 6),
Text(label,
style: const TextStyle(
color: Color(0xFFCFE2FF), fontWeight: FontWeight.w600, fontSize: 14)),
],
),
),
);
}
}