Files
dmc/app/lib/ui/widgets/missions_drones_panel.dart
T
Constantin LeueandClaude Sonnet 5 35351140ae Persist the ground elevation profile with the mission
The Mini-Altitude-Profile terrain data only ever lived in TerrainNotifier's
in-memory state (one slot, keyed by route). Every app restart, or every
switch away from and back to a mission, forced a full re-fetch of all
AWS Terrarium elevation tiles for the route - the noticeably slow load
the user reported for some missions was this happening on every visit,
not just once.

Adds a nullable terrainProfileJson column to the missions table
(schema v6->v7) and a saveTerrainProfile() repository method, kept
separate from the regular waypoint upsert() so an ordinary autosave never
clobbers an already-cached profile. TerrainNotifier persists a profile
right after a successful fetch (fire-and-forget) and gains restore(),
called from CurrentMissionMetaNotifier whenever a mission is loaded/
started so a previously fetched profile is available immediately -
ensureFor() still validates its routeKey before use, so a stale restored
profile is never shown for a route that has since changed.

Also included in mission export/import (MissionExportData/
ParsedMissionImport) so sharing a mission carries its terrain cache along
instead of forcing the recipient to refetch it.

Sample-count/resolution stays as-is for now (still fixed 30m spacing,
10-2000 samples) - adapting the sampling density to terrain variance
(e.g. coarser sampling over flat ground) is a separate follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 08:10:41 +02:00

490 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,
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)),
],
),
),
);
}
}