304 lines
12 KiB
Dart
304 lines
12 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';
|
|
import 'swipe_value_input.dart';
|
|
|
|
/// Formular zum Anlegen/Bearbeiten eines Drohnenprofils (Doku 3.5/7.3).
|
|
/// Numerische Felder als Swipe-Chips (HTML-Demonstrator: SwipeValueInput) -
|
|
/// vertikales Ziehen aendert den Wert in grossen Schritten, horizontales
|
|
/// Ziehen in kleinen Schritten; nur der Name bleibt ein normales Textfeld.
|
|
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, double> _initialValues;
|
|
late Map<String, double> _fieldValues;
|
|
|
|
// Waehrend eine Swipe-Geste auf einem Chip aktiv ist, wird das Formular
|
|
// nicht scrollbar (Doku 3.5) - sonst gewinnt die ListView-eigene
|
|
// Scroll-Geste das Gesture-Arena gegen den Chip, siehe SwipeValueInput.
|
|
bool _scrolling = true;
|
|
|
|
// Min/Max/Schrittweiten 1:1 aus DRONE_EDITOR_GROUPS im HTML-Demonstrator
|
|
// uebernommen.
|
|
static const _groups = [
|
|
(
|
|
title: 'Speed (m/s)',
|
|
fields: [
|
|
(key: 'minSpeed', label: 'Min (stall)', min: 3.0, max: 40.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm/s'),
|
|
(key: 'cruiseSpeed', label: 'Cruise', min: 3.0, max: 50.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm/s'),
|
|
(key: 'maxSpeed', label: 'Max', min: 5.0, max: 80.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm/s'),
|
|
],
|
|
),
|
|
(
|
|
title: 'Radii (m)',
|
|
fields: [
|
|
(key: 'minTurnRadius', label: 'Min turn', min: 5.0, max: 500.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm'),
|
|
(key: 'loiterRadius', label: 'Loiter', min: 20.0, max: 1000.0, stepBig: 10.0, stepSmall: 5.0, decimals: 0, unit: 'm'),
|
|
(key: 'catchRadius', label: 'Waypoint catch', min: 5.0, max: 500.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm'),
|
|
],
|
|
),
|
|
(
|
|
title: 'Climb / Descent (m/s)',
|
|
fields: [
|
|
(key: 'maxClimbRate', label: 'Max climb', min: 0.5, max: 20.0, stepBig: 1.0, stepSmall: 0.5, decimals: 1, unit: 'm/s'),
|
|
(key: 'maxDescentRate', label: 'Max descent', min: 0.5, max: 20.0, stepBig: 1.0, stepSmall: 0.5, decimals: 1, unit: 'm/s'),
|
|
],
|
|
),
|
|
(
|
|
title: 'Limits',
|
|
fields: [
|
|
(key: 'maxRangeM', label: 'Range', min: 500.0, max: 200000.0, stepBig: 1000.0, stepSmall: 100.0, decimals: 0, unit: 'm'),
|
|
(key: 'maxEnduranceMin', label: 'Endurance', min: 5.0, max: 600.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'min'),
|
|
(key: 'maxAltitudeM', label: 'Ceiling', min: 50.0, max: 10000.0, stepBig: 100.0, stepSmall: 10.0, decimals: 0, unit: 'm'),
|
|
],
|
|
),
|
|
// Ampel-Schwellwerte fuer die farbcodierten Telemetriefelder im
|
|
// Drohnen-Status-Menue (Doku: "Schwellwerte fuer die Ampel-Kodierung im
|
|
// Drohnenprofil anlegen", siehe domain/telemetry/telemetry_field_status.dart) -
|
|
// je Feld Gruen-/Rot-Schwelle als eigene Gruppe, analog Climb/Descent oben.
|
|
(
|
|
title: 'GPS satellites alarm',
|
|
fields: [
|
|
(key: 'gpsSatGreenMin', label: 'Green from', min: 0.0, max: 20.0, stepBig: 2.0, stepSmall: 1.0, decimals: 0, unit: 'sat'),
|
|
(key: 'gpsSatRedMin', label: 'Red below', min: 0.0, max: 20.0, stepBig: 2.0, stepSmall: 1.0, decimals: 0, unit: 'sat'),
|
|
],
|
|
),
|
|
(
|
|
title: 'GPS precision (HDOP) alarm',
|
|
fields: [
|
|
(key: 'hdopGreenMax', label: 'Green up to', min: 0.5, max: 10.0, stepBig: 1.0, stepSmall: 0.1, decimals: 1, unit: ''),
|
|
(key: 'hdopRedMax', label: 'Red above', min: 0.5, max: 20.0, stepBig: 1.0, stepSmall: 0.1, decimals: 1, unit: ''),
|
|
],
|
|
),
|
|
(
|
|
title: 'Current draw alarm (A)',
|
|
fields: [
|
|
(key: 'currentGreenMaxA', label: 'Green up to', min: 1.0, max: 200.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'A'),
|
|
(key: 'currentRedMaxA', label: 'Red above', min: 1.0, max: 300.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'A'),
|
|
],
|
|
),
|
|
(
|
|
title: 'Battery voltage alarm (V)',
|
|
fields: [
|
|
(key: 'batteryVoltageGreenMinV', label: 'Green from', min: 5.0, max: 60.0, stepBig: 1.0, stepSmall: 0.1, decimals: 1, unit: 'V'),
|
|
(key: 'batteryVoltageRedMinV', label: 'Red below', min: 5.0, max: 60.0, stepBig: 1.0, stepSmall: 0.1, decimals: 1, unit: 'V'),
|
|
],
|
|
),
|
|
(
|
|
title: 'RX link quality alarm (%)',
|
|
fields: [
|
|
(key: 'rxQualityGreenMin', label: 'Green from', min: 0.0, max: 100.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: '%'),
|
|
(key: 'rxQualityRedMin', label: 'Red below', min: 0.0, max: 100.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: '%'),
|
|
],
|
|
),
|
|
(
|
|
title: 'RX SNR alarm (dB)',
|
|
fields: [
|
|
(key: 'rxSnrGreenMinDb', label: 'Green from', min: -30.0, max: 30.0, stepBig: 5.0, stepSmall: 1.0, decimals: 0, unit: 'dB'),
|
|
(key: 'rxSnrRedMinDb', label: 'Red below', min: -30.0, max: 30.0, stepBig: 5.0, stepSmall: 1.0, decimals: 0, unit: 'dB'),
|
|
],
|
|
),
|
|
];
|
|
|
|
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,
|
|
'gpsSatGreenMin' => p.gpsSatGreenMin,
|
|
'gpsSatRedMin' => p.gpsSatRedMin,
|
|
'hdopGreenMax' => p.hdopGreenMax,
|
|
'hdopRedMax' => p.hdopRedMax,
|
|
'currentGreenMaxA' => p.currentGreenMaxA,
|
|
'currentRedMaxA' => p.currentRedMaxA,
|
|
'batteryVoltageGreenMinV' => p.batteryVoltageGreenMinV,
|
|
'batteryVoltageRedMinV' => p.batteryVoltageRedMinV,
|
|
'rxQualityGreenMin' => p.rxQualityGreenMin,
|
|
'rxQualityRedMin' => p.rxQualityRedMin,
|
|
'rxSnrGreenMinDb' => p.rxSnrGreenMinDb,
|
|
'rxSnrRedMinDb' => p.rxSnrRedMinDb,
|
|
_ => throw ArgumentError(key),
|
|
};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final base = widget.existing ?? DroneProfile.t1Ranger();
|
|
_nameController = TextEditingController(
|
|
text: widget.existing != null ? base.name : 'New profile',
|
|
);
|
|
_initialValues = {
|
|
for (final group in _groups)
|
|
for (final field in group.fields) field.key: _fieldValue(base, field.key),
|
|
};
|
|
_fieldValues = Map.of(_initialValues);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
double v(String key) => _fieldValues[key]!;
|
|
|
|
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'),
|
|
gpsSatGreenMin: v('gpsSatGreenMin'),
|
|
gpsSatRedMin: v('gpsSatRedMin'),
|
|
hdopGreenMax: v('hdopGreenMax'),
|
|
hdopRedMax: v('hdopRedMax'),
|
|
currentGreenMaxA: v('currentGreenMaxA'),
|
|
currentRedMaxA: v('currentRedMaxA'),
|
|
batteryVoltageGreenMinV: v('batteryVoltageGreenMinV'),
|
|
batteryVoltageRedMinV: v('batteryVoltageRedMinV'),
|
|
rxQualityGreenMin: v('rxQualityGreenMin'),
|
|
rxQualityRedMin: v('rxQualityRedMin'),
|
|
rxSnrGreenMinDb: v('rxSnrGreenMinDb'),
|
|
rxSnrRedMinDb: v('rxSnrRedMinDb'),
|
|
);
|
|
|
|
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(
|
|
physics: _scrolling
|
|
? const ClampingScrollPhysics()
|
|
: const NeverScrollableScrollPhysics(),
|
|
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(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
for (final field in group.fields)
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: SwipeValueInput(
|
|
label: field.label,
|
|
value: _fieldValues[field.key]!,
|
|
initialValue: _initialValues[field.key]!,
|
|
min: field.min,
|
|
max: field.max,
|
|
stepBig: field.stepBig,
|
|
stepSmall: field.stepSmall,
|
|
decimals: field.decimals,
|
|
unit: field.unit,
|
|
onChanged: (v) =>
|
|
setState(() => _fieldValues[field.key] = v),
|
|
onDragActiveChanged: (active) =>
|
|
setState(() => _scrolling = !active),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: 24),
|
|
FilledButton(
|
|
onPressed: _save,
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|