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>
219 lines
7.3 KiB
Dart
219 lines
7.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../domain/mission/drone_profile.dart';
|
|
import '../providers/active_drone_profile_provider.dart';
|
|
import '../providers/database_provider.dart';
|
|
|
|
/// Formular zum Anlegen/Bearbeiten eines Drohnenprofils (Doku 3.5/7.3).
|
|
/// Vereinfacht gegenueber dem HTML-Demonstrator: normale Zahlenfelder statt
|
|
/// Swipe-Gesten-Chips (SwipeValueInput) - bei elf Feldern in Gruppen ist ein
|
|
/// Formular auf einem Mobilgeraet die zugaenglichere Eingabemethode.
|
|
class DroneProfileEditor extends ConsumerStatefulWidget {
|
|
const DroneProfileEditor({super.key, this.existing});
|
|
|
|
/// Null fuer ein neues Profil (vorbefuellt mit T1-Ranger-Standardwerten).
|
|
final DroneProfile? existing;
|
|
|
|
@override
|
|
ConsumerState<DroneProfileEditor> createState() => _DroneProfileEditorState();
|
|
}
|
|
|
|
class _DroneProfileEditorState extends ConsumerState<DroneProfileEditor> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
late final TextEditingController _nameController;
|
|
late final Map<String, TextEditingController> _fieldControllers;
|
|
|
|
static const _groups = [
|
|
(
|
|
title: 'Speed (m/s)',
|
|
fields: [
|
|
('minSpeed', 'Min (stall)'),
|
|
('cruiseSpeed', 'Cruise'),
|
|
('maxSpeed', 'Max'),
|
|
],
|
|
),
|
|
(
|
|
title: 'Radii (m)',
|
|
fields: [
|
|
('minTurnRadius', 'Min turn'),
|
|
('loiterRadius', 'Loiter'),
|
|
('catchRadius', 'Waypoint catch'),
|
|
],
|
|
),
|
|
(
|
|
title: 'Climb / Descent (m/s)',
|
|
fields: [
|
|
('maxClimbRate', 'Max climb'),
|
|
('maxDescentRate', 'Max descent'),
|
|
],
|
|
),
|
|
(
|
|
title: 'Limits',
|
|
fields: [
|
|
('maxRangeM', 'Range (m)'),
|
|
('maxEnduranceMin', 'Endurance (min)'),
|
|
('maxAltitudeM', 'Ceiling (m)'),
|
|
],
|
|
),
|
|
];
|
|
|
|
double _fieldValue(DroneProfile p, String key) => switch (key) {
|
|
'minSpeed' => p.minSpeed,
|
|
'cruiseSpeed' => p.cruiseSpeed,
|
|
'maxSpeed' => p.maxSpeed,
|
|
'minTurnRadius' => p.minTurnRadius,
|
|
'loiterRadius' => p.loiterRadius,
|
|
'catchRadius' => p.catchRadius,
|
|
'maxClimbRate' => p.maxClimbRate,
|
|
'maxDescentRate' => p.maxDescentRate,
|
|
'maxRangeM' => p.maxRangeM,
|
|
'maxEnduranceMin' => p.maxEnduranceMin,
|
|
'maxAltitudeM' => p.maxAltitudeM,
|
|
_ => throw ArgumentError(key),
|
|
};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final base = widget.existing ?? DroneProfile.t1Ranger();
|
|
_nameController = TextEditingController(
|
|
text: widget.existing != null ? base.name : 'New profile',
|
|
);
|
|
_fieldControllers = {
|
|
for (final group in _groups)
|
|
for (final (key, _) in group.fields)
|
|
key: TextEditingController(text: _fieldValue(base, key).toString()),
|
|
};
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
for (final c in _fieldControllers.values) {
|
|
c.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
double v(String key) => double.parse(_fieldControllers[key]!.text);
|
|
|
|
final profile = DroneProfile(
|
|
id: widget.existing?.id,
|
|
name: _nameController.text.trim(),
|
|
minSpeed: v('minSpeed'),
|
|
cruiseSpeed: v('cruiseSpeed'),
|
|
maxSpeed: v('maxSpeed'),
|
|
minTurnRadius: v('minTurnRadius'),
|
|
loiterRadius: v('loiterRadius'),
|
|
catchRadius: v('catchRadius'),
|
|
maxClimbRate: v('maxClimbRate'),
|
|
maxDescentRate: v('maxDescentRate'),
|
|
maxRangeM: v('maxRangeM'),
|
|
maxEnduranceMin: v('maxEnduranceMin'),
|
|
maxAltitudeM: v('maxAltitudeM'),
|
|
);
|
|
|
|
final repo = ref.read(droneProfileRepositoryProvider);
|
|
if (profile.id == null) {
|
|
final id = await repo.insert(profile);
|
|
await ref.read(activeDroneProfileProvider.notifier).select(profile.copyWith(id: id));
|
|
} else {
|
|
await repo.update(profile);
|
|
await ref.read(activeDroneProfileProvider.notifier).reloadIfActive(profile.id!);
|
|
}
|
|
if (mounted) Navigator.of(context).pop();
|
|
}
|
|
|
|
@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: [
|
|
Expanded(
|
|
child: Text(
|
|
widget.existing == null ? 'New drone profile' : 'Edit drone profile',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
icon: const Icon(Icons.close, color: Colors.white),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(color: Colors.white24, height: 1),
|
|
Expanded(
|
|
child: Form(
|
|
key: _formKey,
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
TextFormField(
|
|
controller: _nameController,
|
|
style: const TextStyle(color: Colors.white),
|
|
decoration: const InputDecoration(labelText: 'Name'),
|
|
validator: (v) =>
|
|
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
|
),
|
|
for (final group in _groups) ...[
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
group.title,
|
|
style: const TextStyle(
|
|
color: Colors.white70,
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
for (final (key, label) in group.fields)
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: TextFormField(
|
|
controller: _fieldControllers[key],
|
|
style: const TextStyle(color: Colors.white),
|
|
keyboardType: const TextInputType.numberWithOptions(
|
|
decimal: true,
|
|
),
|
|
decoration: InputDecoration(labelText: label),
|
|
validator: (v) =>
|
|
double.tryParse(v ?? '') == null ? 'Number' : null,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: 24),
|
|
FilledButton(
|
|
onPressed: _save,
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|