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>
484 lines
17 KiB
Dart
484 lines
17 KiB
Dart
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)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|