Add mission/drone profile management with autosave and persistence

Vervollstaendigt Doku 3.5/3.6/7.1/7.3/7.5: ein Vollbild-Verwaltungsmenue
fuer Missionen und Drohnenprofile (per Tabs umschaltbar, wie im HTML-
Demonstrator #missionsPanel gemeinsam mit der Profilliste), Autosave der
aktuell bearbeiteten Mission, Drift-Persistenz statt der bisherigen
In-Memory-only currentMissionProvider, sowie Anzeige/Zugang ueber neue
Chips in der Fusszeile (HTML-Demonstrator: #missionNameBtn/#droneNameBtn).

Datenbank (services/database/): Missions-Tabelle um updatedAt ergaenzt,
neue DroneProfiles- und AppSettingsTable-Tabellen (schemaVersion 2 mit
onUpgrade-Migration). Drei Repositories kapseln Drift-Zugriff +
JSON-(De-)Serialisierung der Wegpunktliste: MissionRepository,
DroneProfileRepository, AppSettingsRepository - je mit Unit-Tests gegen
eine In-Memory-Datenbank (NativeDatabase.memory()).

Provider: CurrentMissionMetaNotifier haelt Name/id der aktuellen Mission
und autosaved sie 800ms-debounced (identisch zum HTML-Demonstrator:
scheduleAutosave()/flushPendingAutosave()) - inklusive Wiederherstellung
des zuletzt bearbeiteten Autosave-Drafts beim App-Start (Doku 7.5).
ActiveDroneProfileNotifier haelt das aktive Profil, seedet beim ersten
Start automatisch T1 Ranger und merkt sich die Auswahl ueber
AppSettingsTable neustart-fest.

UI: MissionsDronesPanel (Missions-Tab: Liste mit Umbenennen/Loeschen/
Laden; Drones-Tab: Liste mit Bearbeiten/Loeschen/Auswaehlen, faellt beim
Loeschen des aktiven Profils auf das naechste zurueck). DroneProfileEditor
als Formular (bewusste Vereinfachung gegenueber den Swipe-Gesten-Chips
des HTML-Demonstrators - bei elf Feldern ist ein normales Formular auf
einem Mobilgeraet zugaenglicher). Rename per AlertDialog+TextField statt
Browser-prompt().

Aktives Drohnenprofil ist jetzt tatsaechlich wirksam statt nur eine feste
Anzeige: Speed-/Alt-Drehrad-Grenzen, Fangradius neuer Wegpunkte und die
Flugpfad-Machbarkeitspruefung (Kurvenradius, Steig-/Sinkrate) in
PlanScreen und WaypointListPanel lesen jetzt activeDroneProfileProvider
statt der bisherigen statischen DefaultDroneProfile-Konstanten (die als
T1-Ranger-Seed-Werte weiterleben).

Beim Verifizieren zwei echte Bugs in BottomStatsBar gefunden und
gefixt: ein Stack+Align-ohne-Factor blaehte sich auf unbegrenzte Groesse
auf und verschob die Details-Pille aus ihrer sichtbaren Position (durch
zwei gleich grosse Expanded-Bereiche ersetzt); zwei ConstrainedBox-Chips
nebeneinander verursachten auf schmaleren Breiten einen RenderFlex-
Overflow (durch Flexible ersetzt).

Widget-Tests: AppShell/PlanScreen initialisieren beim Start jetzt die
echte Datenbank - alle Tests, die AppShell pumpen, ueberschreiben
appDatabaseProvider testweise mit einer In-Memory-Instanz. Ausserdem
mussten mehrere Tests den neuen 800ms-Autosave-Timer abwarten (wie
zuvor schon beim WaypointChip-Flash-Timer etabliert), damit nach
Testende kein Timer mehr aussteht.

Verifiziert: flutter analyze (0 issues), flutter test (59/59, davon 14
neue Repository-Tests), manuell auf Pixel_10a-Emulator - Wegpunkte
werden automatisch als "New mission" gespeichert und erscheinen in der
Liste, Umbenennen/Laden/Loeschen funktionieren, neues Drohnenprofil mit
abweichender Max-Speed wird angelegt+aktiviert+in der Fusszeile
angezeigt, Loeschen des aktiven Profils faellt auf das verbleibende
zurueck.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Constantin Leue
2026-07-28 14:59:09 +02:00
co-authored by Claude Sonnet 5
parent 3dd9c38fb1
commit aaec7f0f72
18 changed files with 3372 additions and 76 deletions
@@ -0,0 +1,373 @@
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 '../providers/active_drone_profile_provider.dart';
import '../providers/database_provider.dart';
import '../providers/mission_meta_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: Align(
alignment: Alignment.centerLeft,
child: _ToolbarButton(
icon: Icons.add,
label: 'New mission',
onTap: () async {
await ref.read(currentMissionMetaProvider.notifier).startNew();
if (mounted) Navigator.of(context).pop();
},
),
),
),
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: () => ref.read(missionRepositoryProvider).delete(m.id),
icon: const Icon(Icons.delete_outline,
color: Color(0xFFE2574A), size: 18),
),
],
),
),
);
}
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: Align(
alignment: Alignment.centerLeft,
child: _ToolbarButton(
icon: Icons.add,
label: 'New profile',
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const DroneProfileEditor(),
)),
),
),
),
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: () => _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);
}
}
}
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)),
],
),
),
);
}
}