Same change as the drone status panel: drop the title row, float the close button over the tab row (reserving 48px on its right), and give the tabs more vertical padding so they don't look cramped without the title above them.
493 lines
17 KiB
Dart
493 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(
|
||
// Kein eigener Titel-Header mehr (Doku: analog zum Drone-Status-
|
||
// Panel, mehr Platz fuer den Inhalt) - der Schliessen-Knopf
|
||
// schwebt stattdessen ueber der Tab-Zeile, die dafuer rechts Platz
|
||
// frei laesst (siehe unten), statt eine ganze Zeile fuer
|
||
// Titel+Knopf zu opfern.
|
||
child: Stack(
|
||
children: [
|
||
Column(
|
||
children: [
|
||
Padding(
|
||
// Rechts Platz fuer den schwebenden Schliessen-Knopf, damit
|
||
// "Drones" nicht darunter verschwindet.
|
||
padding: const EdgeInsets.only(right: 48),
|
||
child: 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(),
|
||
},
|
||
),
|
||
],
|
||
),
|
||
Positioned(
|
||
top: 4,
|
||
right: 4,
|
||
child: IconButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
icon: const Icon(Icons.close, color: Colors.white),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _tabButton(String label, _ManagementTab tab) {
|
||
final active = _tab == tab;
|
||
return Expanded(
|
||
child: InkWell(
|
||
onTap: () => setState(() => _tab = tab),
|
||
child: Container(
|
||
// 18 statt vorher 10 (analog Drone-Status-Panel: Tabs wirkten
|
||
// seit dem Wegfall der Titelzeile zu gequetscht).
|
||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||
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,
|
||
terrainProfile: full.terrainProfile,
|
||
));
|
||
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;
|
||
}
|
||
final id = await ref.read(missionRepositoryProvider).upsert(
|
||
name: parsed.name,
|
||
placeName: parsed.placeName,
|
||
waypoints: parsed.waypoints,
|
||
);
|
||
if (parsed.terrainProfile != null) {
|
||
await ref
|
||
.read(missionRepositoryProvider)
|
||
.saveTerrainProfile(id, parsed.terrainProfile!);
|
||
}
|
||
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)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|