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:
co-authored by
Claude Sonnet 5
parent
3dd9c38fb1
commit
aaec7f0f72
@@ -1,11 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/active_drone_profile_provider.dart';
|
||||
import '../providers/mission_meta_provider.dart';
|
||||
import 'missions_drones_panel.dart';
|
||||
|
||||
/// Statuszeile am unteren Bildschirmrand (HTML-Demonstrator: #bottomBar/
|
||||
/// #statsRow). Antippen oeffnet die Wegpunktliste. Mission-/Drohnen-Name
|
||||
/// und der Send-Button sind bewusst nicht Teil dieses Schritts - die
|
||||
/// dafuer noetige Missionsverwaltung bzw. Upload-Anbindung existiert noch
|
||||
/// nicht.
|
||||
class BottomStatsBar extends StatelessWidget {
|
||||
/// #statsRow). Details-Pille (mittig) oeffnet die Wegpunktliste; die
|
||||
/// Mission-/Drohnen-Chips (links) oeffnen das Verwaltungsmenue mit dem
|
||||
/// passenden Starttab (HTML-Demonstrator: #missionNameBtn/#droneNameBtn).
|
||||
/// Der Send-Button ist bewusst nicht Teil dieses Schritts - die dafuer
|
||||
/// noetige Upload-Anbindung existiert noch nicht.
|
||||
class BottomStatsBar extends ConsumerWidget {
|
||||
const BottomStatsBar({
|
||||
super.key,
|
||||
required this.waypointCount,
|
||||
@@ -20,50 +26,97 @@ class BottomStatsBar extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final missionName = ref.watch(currentMissionMetaProvider).name;
|
||||
final droneName = ref.watch(activeDroneProfileProvider).name;
|
||||
|
||||
// Row statt Stack: ein Align/Center ohne width-/heightFactor nimmt sich
|
||||
// immer den gesamten verfuegbaren Platz seines Elternwidgets (auch wenn
|
||||
// sein Kind viel kleiner ist) - in einem Stack ohne begrenzende
|
||||
// Positioned-Kinder blaeht das dessen eigene Groesse unkontrolliert auf
|
||||
// und verschiebt die Details-Pille aus ihrer sichtbaren Position.
|
||||
// Zwei gleich grosse Expanded-Bereiche links/rechts der Pille halten sie
|
||||
// dagegen zuverlaessig zentriert.
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Details:',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: _NameChip(
|
||||
label: 'Mission: $missionName',
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
textColor: Colors.white,
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => const MissionsDronesPanel.missions(),
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Icon(Icons.flag, size: 14, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text('$waypointCount WP',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||
const SizedBox(width: 14),
|
||||
const Icon(Icons.straighten, size: 14, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text('${totalDistanceM.round()} m',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||
const SizedBox(width: 14),
|
||||
const Icon(Icons.access_time, size: 14, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text(_fmtDuration(totalDurationSec),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||
],
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: _NameChip(
|
||||
label: 'Drone: $droneName',
|
||||
color: const Color(0xFF4D9DFF).withValues(alpha: 0.18),
|
||||
textColor: const Color(0xFFCFE2FF),
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => const MissionsDronesPanel.drones(),
|
||||
)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_detailsPill(),
|
||||
const Expanded(child: SizedBox.shrink()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detailsPill() {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Details:',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Icon(Icons.flag, size: 14, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text('$waypointCount WP',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||
const SizedBox(width: 14),
|
||||
const Icon(Icons.straighten, size: 14, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text('${totalDistanceM.round()} m',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||
const SizedBox(width: 14),
|
||||
const Icon(Icons.access_time, size: 14, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text(_fmtDuration(totalDurationSec),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -76,3 +129,44 @@ class BottomStatsBar extends StatelessWidget {
|
||||
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
class _NameChip extends StatelessWidget {
|
||||
const _NameChip({
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.textColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color textColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 130),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../domain/mission/default_drone_profile.dart';
|
||||
import '../../domain/mission/drone_profile.dart';
|
||||
import '../../domain/mission/route_geometry.dart';
|
||||
import '../../domain/waypoint/flat_waypoint_list.dart';
|
||||
import '../../domain/wind/wind_math.dart';
|
||||
import '../providers/active_drone_profile_provider.dart';
|
||||
import '../providers/current_mission_provider.dart';
|
||||
import '../providers/wind_provider.dart';
|
||||
import 'full_value_chart.dart';
|
||||
@@ -134,11 +136,12 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final waypoints = ref.watch(currentMissionProvider);
|
||||
final droneProfile = ref.watch(activeDroneProfileProvider);
|
||||
final routeGeometry = buildRouteGeometry(
|
||||
waypoints,
|
||||
minTurnRadius: DefaultDroneProfile.minTurnRadius,
|
||||
maxClimbRate: DefaultDroneProfile.maxClimbRate,
|
||||
maxDescentRate: DefaultDroneProfile.maxDescentRate,
|
||||
minTurnRadius: droneProfile.minTurnRadius,
|
||||
maxClimbRate: droneProfile.maxClimbRate,
|
||||
maxDescentRate: droneProfile.maxDescentRate,
|
||||
);
|
||||
|
||||
return Material(
|
||||
@@ -190,13 +193,13 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
||||
const Divider(color: Colors.white24, height: 1),
|
||||
Expanded(
|
||||
child: switch (_tab) {
|
||||
_PanelTab.list => _listTab(waypoints, routeGeometry),
|
||||
_PanelTab.list => _listTab(waypoints, routeGeometry, droneProfile),
|
||||
_PanelTab.altitude => _chartTab(
|
||||
waypoints,
|
||||
routeGeometry,
|
||||
values: [for (final w in waypoints) w.altitudeM],
|
||||
min: DefaultDroneProfile.altMin,
|
||||
max: DefaultDroneProfile.altMax,
|
||||
max: droneProfile.maxAltitudeM,
|
||||
step: DefaultDroneProfile.altStep,
|
||||
pxPerUnit: 5,
|
||||
unit: ' m',
|
||||
@@ -206,8 +209,8 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
||||
waypoints,
|
||||
routeGeometry,
|
||||
values: [for (final w in waypoints) w.speedMs],
|
||||
min: DefaultDroneProfile.speedMin,
|
||||
max: DefaultDroneProfile.speedMax,
|
||||
min: droneProfile.minSpeed,
|
||||
max: droneProfile.maxSpeed,
|
||||
step: DefaultDroneProfile.speedStep,
|
||||
pxPerUnit: 22,
|
||||
unit: ' m/s',
|
||||
@@ -290,7 +293,11 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _listTab(List<Waypoint> waypoints, RouteGeometry routeGeometry) {
|
||||
Widget _listTab(
|
||||
List<Waypoint> waypoints,
|
||||
RouteGeometry routeGeometry,
|
||||
DroneProfile droneProfile,
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
if (waypoints.isNotEmpty)
|
||||
@@ -322,14 +329,19 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
||||
itemExtent: _rowExtent,
|
||||
itemCount: waypoints.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_row(waypoints[index], index, routeGeometry),
|
||||
_row(waypoints[index], index, routeGeometry, droneProfile),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(Waypoint wp, int index, RouteGeometry routeGeometry) {
|
||||
Widget _row(
|
||||
Waypoint wp,
|
||||
int index,
|
||||
RouteGeometry routeGeometry,
|
||||
DroneProfile droneProfile,
|
||||
) {
|
||||
final isEditing = _rowEditIndex == index;
|
||||
final isActive = widget.activeIndex == index;
|
||||
final isBad = index < routeGeometry.vertexBad.length &&
|
||||
@@ -371,7 +383,7 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
||||
value: wp.altitudeM,
|
||||
unit: ' m',
|
||||
min: DefaultDroneProfile.altMin,
|
||||
max: DefaultDroneProfile.altMax,
|
||||
max: droneProfile.maxAltitudeM,
|
||||
step: DefaultDroneProfile.altStep,
|
||||
onChanged: (v) => widget.onAltitudeChanged(index, v),
|
||||
)
|
||||
@@ -383,8 +395,8 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
||||
? WaypointChip(
|
||||
value: wp.speedMs,
|
||||
unit: ' m/s',
|
||||
min: DefaultDroneProfile.speedMin,
|
||||
max: DefaultDroneProfile.speedMax,
|
||||
min: droneProfile.minSpeed,
|
||||
max: droneProfile.maxSpeed,
|
||||
step: DefaultDroneProfile.speedStep,
|
||||
onChanged: (v) => widget.onSpeedChanged(index, v),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user