Files
dmc/app/lib/ui/widgets/missions_drones_panel.dart
T
Constantin Leue d8a0809935 Apply the drone status panel's header treatment to Missions & Drones
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.
2026-08-07 23:14:50 +02:00

493 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(
// 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)),
],
),
),
);
}
}