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
@@ -0,0 +1,96 @@
|
|||||||
|
/// Ein Drohnen-Profil mit Flugleistungsdaten (Architektur-Doku 3.5/7.3):
|
||||||
|
/// FC-Typ ist bewusst NICHT Teil dieses MVP-Profils (nur iNAV/MSP im
|
||||||
|
/// Scope, siehe CLAUDE.md) - anders als im HTML-Demonstrator, der bereits
|
||||||
|
/// auf MAVLink vorbereitet.
|
||||||
|
class DroneProfile {
|
||||||
|
const DroneProfile({
|
||||||
|
this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.minSpeed,
|
||||||
|
required this.cruiseSpeed,
|
||||||
|
required this.maxSpeed,
|
||||||
|
required this.minTurnRadius,
|
||||||
|
required this.loiterRadius,
|
||||||
|
required this.catchRadius,
|
||||||
|
required this.maxClimbRate,
|
||||||
|
required this.maxDescentRate,
|
||||||
|
required this.maxRangeM,
|
||||||
|
required this.maxEnduranceMin,
|
||||||
|
required this.maxAltitudeM,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Null fuer ein noch nicht gespeichertes (neu angelegtes) Profil.
|
||||||
|
final int? id;
|
||||||
|
final String name;
|
||||||
|
final double minSpeed;
|
||||||
|
final double cruiseSpeed;
|
||||||
|
final double maxSpeed;
|
||||||
|
final double minTurnRadius;
|
||||||
|
final double loiterRadius;
|
||||||
|
final double catchRadius;
|
||||||
|
final double maxClimbRate;
|
||||||
|
final double maxDescentRate;
|
||||||
|
final double maxRangeM;
|
||||||
|
final double maxEnduranceMin;
|
||||||
|
final double maxAltitudeM;
|
||||||
|
|
||||||
|
/// T1 Ranger (Heewing) - Standardprofil (Doku 3.5), analog DEFAULT_DRONE
|
||||||
|
/// im HTML-Demonstrator.
|
||||||
|
static const _t1RangerName = 'T1 Ranger';
|
||||||
|
|
||||||
|
factory DroneProfile.t1Ranger() => const DroneProfile(
|
||||||
|
name: _t1RangerName,
|
||||||
|
minSpeed: 13,
|
||||||
|
cruiseSpeed: 15,
|
||||||
|
maxSpeed: 25,
|
||||||
|
minTurnRadius: 35,
|
||||||
|
loiterRadius: 100,
|
||||||
|
catchRadius: 60,
|
||||||
|
maxClimbRate: 5,
|
||||||
|
maxDescentRate: 6,
|
||||||
|
maxRangeM: 15000,
|
||||||
|
maxEnduranceMin: 45,
|
||||||
|
maxAltitudeM: 2000,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Einzeilige Zusammenfassung fuer die Profilliste (Doku 3.5), analog
|
||||||
|
/// droneSummary() im HTML-Demonstrator.
|
||||||
|
String get summary {
|
||||||
|
final rangeKm = (maxRangeM / 100).round() / 10;
|
||||||
|
return '$minSpeed–$cruiseSpeed–$maxSpeed m/s · R${minTurnRadius.round()}m · '
|
||||||
|
'↑${maxClimbRate.round()}/↓${maxDescentRate.round()} m/s · ${rangeKm}km · '
|
||||||
|
'${maxEnduranceMin.round()}min · ceil ${maxAltitudeM.round()}m';
|
||||||
|
}
|
||||||
|
|
||||||
|
DroneProfile copyWith({
|
||||||
|
int? id,
|
||||||
|
String? name,
|
||||||
|
double? minSpeed,
|
||||||
|
double? cruiseSpeed,
|
||||||
|
double? maxSpeed,
|
||||||
|
double? minTurnRadius,
|
||||||
|
double? loiterRadius,
|
||||||
|
double? catchRadius,
|
||||||
|
double? maxClimbRate,
|
||||||
|
double? maxDescentRate,
|
||||||
|
double? maxRangeM,
|
||||||
|
double? maxEnduranceMin,
|
||||||
|
double? maxAltitudeM,
|
||||||
|
}) {
|
||||||
|
return DroneProfile(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
minSpeed: minSpeed ?? this.minSpeed,
|
||||||
|
cruiseSpeed: cruiseSpeed ?? this.cruiseSpeed,
|
||||||
|
maxSpeed: maxSpeed ?? this.maxSpeed,
|
||||||
|
minTurnRadius: minTurnRadius ?? this.minTurnRadius,
|
||||||
|
loiterRadius: loiterRadius ?? this.loiterRadius,
|
||||||
|
catchRadius: catchRadius ?? this.catchRadius,
|
||||||
|
maxClimbRate: maxClimbRate ?? this.maxClimbRate,
|
||||||
|
maxDescentRate: maxDescentRate ?? this.maxDescentRate,
|
||||||
|
maxRangeM: maxRangeM ?? this.maxRangeM,
|
||||||
|
maxEnduranceMin: maxEnduranceMin ?? this.maxEnduranceMin,
|
||||||
|
maxAltitudeM: maxAltitudeM ?? this.maxAltitudeM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,17 +17,64 @@ class Missions extends Table {
|
|||||||
TextColumn get location => text().nullable()();
|
TextColumn get location => text().nullable()();
|
||||||
DateTimeColumn get createdAt =>
|
DateTimeColumn get createdAt =>
|
||||||
dateTime().withDefault(currentDateAndTime)();
|
dateTime().withDefault(currentDateAndTime)();
|
||||||
|
DateTimeColumn get updatedAt =>
|
||||||
|
dateTime().withDefault(currentDateAndTime)();
|
||||||
TextColumn get waypointsJson => text()();
|
TextColumn get waypointsJson => text()();
|
||||||
BoolColumn get isDirty => boolean().withDefault(const Constant(true))();
|
BoolColumn get isDirty => boolean().withDefault(const Constant(true))();
|
||||||
TextColumn get serverId => text().nullable()();
|
TextColumn get serverId => text().nullable()();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DriftDatabase(tables: [Missions])
|
/// Drohnen-Profile (Doku 3.5/7.3) - FC-Typ bewusst nicht Teil des Schemas,
|
||||||
|
/// siehe DroneProfile-Domainklasse.
|
||||||
|
class DroneProfiles extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get name => text()();
|
||||||
|
RealColumn get minSpeed => real()();
|
||||||
|
RealColumn get cruiseSpeed => real()();
|
||||||
|
RealColumn get maxSpeed => real()();
|
||||||
|
RealColumn get minTurnRadius => real()();
|
||||||
|
RealColumn get loiterRadius => real()();
|
||||||
|
RealColumn get catchRadius => real()();
|
||||||
|
RealColumn get maxClimbRate => real()();
|
||||||
|
RealColumn get maxDescentRate => real()();
|
||||||
|
RealColumn get maxRangeM => real()();
|
||||||
|
RealColumn get maxEnduranceMin => real()();
|
||||||
|
RealColumn get maxAltitudeM => real()();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einzeilige Einstellungstabelle (id fest 0) fuer app-weite Auswahl, die
|
||||||
|
/// einen Neustart ueberleben soll: zuletzt aktives Drohnenprofil und die
|
||||||
|
/// zuletzt bearbeitete (autosaved) Mission, um sie beim naechsten Start
|
||||||
|
/// fortzusetzen (Doku 7.5: Autosave/Mission-Drafts).
|
||||||
|
class AppSettingsTable extends Table {
|
||||||
|
IntColumn get id => integer()();
|
||||||
|
IntColumn get activeDroneProfileId => integer().nullable()();
|
||||||
|
IntColumn get currentMissionId => integer().nullable()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
@DriftDatabase(tables: [Missions, DroneProfiles, AppSettingsTable])
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase() : super(_openConnection());
|
AppDatabase() : super(_openConnection());
|
||||||
|
|
||||||
|
AppDatabase.forTesting(super.executor);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 1;
|
int get schemaVersion => 2;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
|
onCreate: (m) => m.createAll(),
|
||||||
|
onUpgrade: (m, from, to) async {
|
||||||
|
if (from < 2) {
|
||||||
|
await m.addColumn(missions, missions.updatedAt);
|
||||||
|
await m.createTable(droneProfiles);
|
||||||
|
await m.createTable(appSettingsTable);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
static LazyDatabase _openConnection() {
|
static LazyDatabase _openConnection() {
|
||||||
return LazyDatabase(() async {
|
return LazyDatabase(() async {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import 'app_database.dart';
|
||||||
|
|
||||||
|
/// Zugriff auf die Einzeilen-Einstellungstabelle (Doku 7.5): welches
|
||||||
|
/// Drohnenprofil zuletzt aktiv war und welche Mission zuletzt bearbeitet
|
||||||
|
/// wurde, um beide nach einem Neustart wiederherzustellen.
|
||||||
|
class AppSettingsRepository {
|
||||||
|
AppSettingsRepository(this._db);
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
|
static const _rowId = 0;
|
||||||
|
|
||||||
|
Future<({int? activeDroneProfileId, int? currentMissionId})> load() async {
|
||||||
|
final row = await (_db.select(_db.appSettingsTable)
|
||||||
|
..where((s) => s.id.equals(_rowId)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return (
|
||||||
|
activeDroneProfileId: row?.activeDroneProfileId,
|
||||||
|
currentMissionId: row?.currentMissionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setActiveDroneProfileId(int? id) async {
|
||||||
|
await _upsert(AppSettingsTableCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
activeDroneProfileId: Value(id),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setCurrentMissionId(int? id) async {
|
||||||
|
await _upsert(AppSettingsTableCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
currentMissionId: Value(id),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _upsert(AppSettingsTableCompanion companion) async {
|
||||||
|
await _db.into(_db.appSettingsTable).insertOnConflictUpdate(companion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../../domain/mission/drone_profile.dart' as domain;
|
||||||
|
import 'app_database.dart';
|
||||||
|
|
||||||
|
/// Persistenz-Zugriff fuer Drohnenprofile (Doku 3.5/7.3).
|
||||||
|
///
|
||||||
|
/// `domain.DroneProfile` (das reine Datenmodell) und `DroneProfile` (die von
|
||||||
|
/// Drift aus der `DroneProfiles`-Tabelle generierte Zeilenklasse) heissen
|
||||||
|
/// gleich - hier daher qualifiziert importiert, um beide zu unterscheiden.
|
||||||
|
class DroneProfileRepository {
|
||||||
|
DroneProfileRepository(this._db);
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
|
Stream<List<domain.DroneProfile>> watchAll() {
|
||||||
|
return _db.select(_db.droneProfiles).watch().map(
|
||||||
|
(rows) => [for (final row in rows) _fromRow(row)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<domain.DroneProfile>> loadAll() async {
|
||||||
|
final rows = await _db.select(_db.droneProfiles).get();
|
||||||
|
return [for (final row in rows) _fromRow(row)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt beim allerersten Start ein T1-Ranger-Standardprofil an, falls
|
||||||
|
/// noch keines existiert (analog `if (!drones) drones=[DEFAULT_DRONE]`
|
||||||
|
/// im HTML-Demonstrator). Gibt dessen id zurueck.
|
||||||
|
Future<int> ensureSeeded() async {
|
||||||
|
final existing = await loadAll();
|
||||||
|
if (existing.isNotEmpty) return existing.first.id!;
|
||||||
|
return insert(domain.DroneProfile.t1Ranger());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> insert(domain.DroneProfile profile) {
|
||||||
|
return _db.into(_db.droneProfiles).insert(_toCompanion(profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> update(domain.DroneProfile profile) async {
|
||||||
|
await (_db.update(_db.droneProfiles)..where((d) => d.id.equals(profile.id!)))
|
||||||
|
.write(_toCompanion(profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int id) async {
|
||||||
|
await (_db.delete(_db.droneProfiles)..where((d) => d.id.equals(id))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
domain.DroneProfile _fromRow(DroneProfile row) => domain.DroneProfile(
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
minSpeed: row.minSpeed,
|
||||||
|
cruiseSpeed: row.cruiseSpeed,
|
||||||
|
maxSpeed: row.maxSpeed,
|
||||||
|
minTurnRadius: row.minTurnRadius,
|
||||||
|
loiterRadius: row.loiterRadius,
|
||||||
|
catchRadius: row.catchRadius,
|
||||||
|
maxClimbRate: row.maxClimbRate,
|
||||||
|
maxDescentRate: row.maxDescentRate,
|
||||||
|
maxRangeM: row.maxRangeM,
|
||||||
|
maxEnduranceMin: row.maxEnduranceMin,
|
||||||
|
maxAltitudeM: row.maxAltitudeM,
|
||||||
|
);
|
||||||
|
|
||||||
|
DroneProfilesCompanion _toCompanion(domain.DroneProfile profile) =>
|
||||||
|
DroneProfilesCompanion(
|
||||||
|
name: Value(profile.name),
|
||||||
|
minSpeed: Value(profile.minSpeed),
|
||||||
|
cruiseSpeed: Value(profile.cruiseSpeed),
|
||||||
|
maxSpeed: Value(profile.maxSpeed),
|
||||||
|
minTurnRadius: Value(profile.minTurnRadius),
|
||||||
|
loiterRadius: Value(profile.loiterRadius),
|
||||||
|
catchRadius: Value(profile.catchRadius),
|
||||||
|
maxClimbRate: Value(profile.maxClimbRate),
|
||||||
|
maxDescentRate: Value(profile.maxDescentRate),
|
||||||
|
maxRangeM: Value(profile.maxRangeM),
|
||||||
|
maxEnduranceMin: Value(profile.maxEnduranceMin),
|
||||||
|
maxAltitudeM: Value(profile.maxAltitudeM),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../../domain/waypoint/flat_waypoint_list.dart';
|
||||||
|
import 'app_database.dart';
|
||||||
|
|
||||||
|
/// Kopfzeilen-taugliche Zusammenfassung einer gespeicherten Mission, ohne
|
||||||
|
/// die komplette Wegpunktliste zu materialisieren (fuer die Missionsliste
|
||||||
|
/// im Verwaltungsmenue, Doku 3.6/7.1).
|
||||||
|
class MissionSummary {
|
||||||
|
const MissionSummary({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.placeName,
|
||||||
|
required this.waypointCount,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String name;
|
||||||
|
final String? placeName;
|
||||||
|
final int waypointCount;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoadedMission {
|
||||||
|
const LoadedMission({
|
||||||
|
required this.name,
|
||||||
|
required this.placeName,
|
||||||
|
required this.waypoints,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String? placeName;
|
||||||
|
final List<Waypoint> waypoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistenz-Zugriff fuer Missionen (Doku 4.15): kapselt die Drift-Tabelle
|
||||||
|
/// und die JSON-(De-)Serialisierung der Wegpunktliste - die Domain-Schicht
|
||||||
|
/// (Waypoint) kennt bewusst kein JSON, siehe flat_waypoint_list.dart.
|
||||||
|
class MissionRepository {
|
||||||
|
MissionRepository(this._db);
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
|
Stream<List<MissionSummary>> watchSummaries() {
|
||||||
|
return _db.select(_db.missions).watch().map((rows) {
|
||||||
|
final summaries = [
|
||||||
|
for (final row in rows)
|
||||||
|
MissionSummary(
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
placeName: row.location,
|
||||||
|
waypointCount:
|
||||||
|
(jsonDecode(row.waypointsJson) as List).length,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return summaries;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<LoadedMission?> load(int id) async {
|
||||||
|
final row = await (_db.select(_db.missions)
|
||||||
|
..where((m) => m.id.equals(id)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (row == null) return null;
|
||||||
|
final decoded = jsonDecode(row.waypointsJson) as List;
|
||||||
|
return LoadedMission(
|
||||||
|
name: row.name,
|
||||||
|
placeName: row.location,
|
||||||
|
waypoints: [
|
||||||
|
for (final entry in decoded) _waypointFromJson(entry as Map<String, dynamic>),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt eine neue Mission an oder ueberschreibt die bestehende mit [id] -
|
||||||
|
/// analog saveCurrentMission()/Mission.toRecord() im HTML-Demonstrator
|
||||||
|
/// (find-or-insert nach id). Gibt die (ggf. neu vergebene) id zurueck.
|
||||||
|
Future<int> upsert({
|
||||||
|
int? id,
|
||||||
|
required String name,
|
||||||
|
String? placeName,
|
||||||
|
required List<Waypoint> waypoints,
|
||||||
|
}) async {
|
||||||
|
final waypointsJson = jsonEncode([for (final w in waypoints) _waypointToJson(w)]);
|
||||||
|
final companion = MissionsCompanion(
|
||||||
|
name: Value(name),
|
||||||
|
location: Value(placeName),
|
||||||
|
updatedAt: Value(DateTime.now()),
|
||||||
|
waypointsJson: Value(waypointsJson),
|
||||||
|
);
|
||||||
|
if (id == null) {
|
||||||
|
return _db.into(_db.missions).insert(companion);
|
||||||
|
}
|
||||||
|
final existing = await (_db.select(_db.missions)
|
||||||
|
..where((m) => m.id.equals(id)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (existing == null) {
|
||||||
|
return _db.into(_db.missions).insert(companion.copyWith(id: Value(id)));
|
||||||
|
}
|
||||||
|
await (_db.update(_db.missions)..where((m) => m.id.equals(id)))
|
||||||
|
.write(companion);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> rename(int id, String name) async {
|
||||||
|
await (_db.update(_db.missions)..where((m) => m.id.equals(id))).write(
|
||||||
|
MissionsCompanion(name: Value(name), updatedAt: Value(DateTime.now())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int id) async {
|
||||||
|
await (_db.delete(_db.missions)..where((m) => m.id.equals(id))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _waypointToJson(Waypoint w) => {
|
||||||
|
'lat': w.lat,
|
||||||
|
'lon': w.lon,
|
||||||
|
'altitudeM': w.altitudeM,
|
||||||
|
'speedMs': w.speedMs,
|
||||||
|
'catchRadiusM': w.catchRadiusM,
|
||||||
|
'action': w.action.name,
|
||||||
|
'jumpTargetIndex': w.jumpTargetIndex,
|
||||||
|
'jumpRepeatCount': w.jumpRepeatCount,
|
||||||
|
'windSpeedMs': w.windSpeedMs,
|
||||||
|
'windDirFromDeg': w.windDirFromDeg,
|
||||||
|
'windElevationM': w.windElevationM,
|
||||||
|
};
|
||||||
|
|
||||||
|
Waypoint _waypointFromJson(Map<String, dynamic> json) => Waypoint(
|
||||||
|
lat: (json['lat'] as num).toDouble(),
|
||||||
|
lon: (json['lon'] as num).toDouble(),
|
||||||
|
altitudeM: (json['altitudeM'] as num).toDouble(),
|
||||||
|
speedMs: (json['speedMs'] as num).toDouble(),
|
||||||
|
catchRadiusM: (json['catchRadiusM'] as num).toDouble(),
|
||||||
|
action: WaypointAction.values.firstWhere(
|
||||||
|
(a) => a.name == json['action'],
|
||||||
|
orElse: () => WaypointAction.none,
|
||||||
|
),
|
||||||
|
jumpTargetIndex: json['jumpTargetIndex'] as int?,
|
||||||
|
jumpRepeatCount: json['jumpRepeatCount'] as int?,
|
||||||
|
windSpeedMs: (json['windSpeedMs'] as num?)?.toDouble(),
|
||||||
|
windDirFromDeg: (json['windDirFromDeg'] as num?)?.toDouble(),
|
||||||
|
windElevationM: (json['windElevationM'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../domain/mission/drone_profile.dart';
|
||||||
|
import 'database_provider.dart';
|
||||||
|
|
||||||
|
/// Haelt das aktuell aktive Drohnenprofil (Doku 3.5/7.3) - bestimmt u.a. den
|
||||||
|
/// gueltigen Geschwindigkeits-/Hoehenbereich der Drehraeder sowie die
|
||||||
|
/// Flugpfad-Machbarkeitspruefung (Kurvenradius, Steig-/Sinkrate).
|
||||||
|
///
|
||||||
|
/// T1-Ranger-Konstanten dienen nur als Startwert, bevor initialize() die
|
||||||
|
/// Datenbank gelesen hat (verhindert einen Zustand ganz ohne Profil waehrend
|
||||||
|
/// des asynchronen Ladens).
|
||||||
|
class ActiveDroneProfileNotifier extends Notifier<DroneProfile> {
|
||||||
|
@override
|
||||||
|
DroneProfile build() => DroneProfile.t1Ranger();
|
||||||
|
|
||||||
|
/// Stellt sicher, dass mindestens ein Profil in der Datenbank existiert,
|
||||||
|
/// und laedt das zuletzt aktive (oder das erste vorhandene) Profil -
|
||||||
|
/// analog dem drones/activeDrone-Bootstrap im HTML-Demonstrator.
|
||||||
|
Future<void> initialize() async {
|
||||||
|
final droneRepo = ref.read(droneProfileRepositoryProvider);
|
||||||
|
final settingsRepo = ref.read(appSettingsRepositoryProvider);
|
||||||
|
|
||||||
|
final seededId = await droneRepo.ensureSeeded();
|
||||||
|
final settings = await settingsRepo.load();
|
||||||
|
final all = await droneRepo.loadAll();
|
||||||
|
|
||||||
|
final activeId = settings.activeDroneProfileId ?? seededId;
|
||||||
|
final active = all.firstWhere(
|
||||||
|
(p) => p.id == activeId,
|
||||||
|
orElse: () => all.first,
|
||||||
|
);
|
||||||
|
state = active;
|
||||||
|
if (settings.activeDroneProfileId != active.id) {
|
||||||
|
await settingsRepo.setActiveDroneProfileId(active.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> select(DroneProfile profile) async {
|
||||||
|
state = profile;
|
||||||
|
await ref.read(appSettingsRepositoryProvider).setActiveDroneProfileId(profile.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nach dem Bearbeiten des aktiven Profils dessen Werte neu einlesen.
|
||||||
|
Future<void> reloadIfActive(int id) async {
|
||||||
|
if (state.id != id) return;
|
||||||
|
final all = await ref.read(droneProfileRepositoryProvider).loadAll();
|
||||||
|
final updated = all.where((p) => p.id == id);
|
||||||
|
if (updated.isNotEmpty) state = updated.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nach dem Loeschen des aktiven Profils auf das erste verbleibende
|
||||||
|
/// zurueckfallen (analog `if (wasActive) applyDroneProfile(drones[0])`).
|
||||||
|
Future<void> fallBackTo(DroneProfile profile) async {
|
||||||
|
state = profile;
|
||||||
|
await ref.read(appSettingsRepositoryProvider).setActiveDroneProfileId(profile.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final activeDroneProfileProvider =
|
||||||
|
NotifierProvider<ActiveDroneProfileNotifier, DroneProfile>(
|
||||||
|
ActiveDroneProfileNotifier.new);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../domain/mission/drone_profile.dart';
|
||||||
|
import '../../services/database/app_database.dart' hide DroneProfile;
|
||||||
|
import '../../services/database/app_settings_repository.dart';
|
||||||
|
import '../../services/database/drone_profile_repository.dart';
|
||||||
|
import '../../services/database/mission_repository.dart';
|
||||||
|
|
||||||
|
/// App-lebenslange Drift-Datenbankinstanz (Doku 4.15).
|
||||||
|
final appDatabaseProvider = Provider<AppDatabase>((ref) {
|
||||||
|
final db = AppDatabase();
|
||||||
|
ref.onDispose(db.close);
|
||||||
|
return db;
|
||||||
|
});
|
||||||
|
|
||||||
|
final missionRepositoryProvider = Provider<MissionRepository>(
|
||||||
|
(ref) => MissionRepository(ref.watch(appDatabaseProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final droneProfileRepositoryProvider = Provider<DroneProfileRepository>(
|
||||||
|
(ref) => DroneProfileRepository(ref.watch(appDatabaseProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final appSettingsRepositoryProvider = Provider<AppSettingsRepository>(
|
||||||
|
(ref) => AppSettingsRepository(ref.watch(appDatabaseProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Reaktive Missionsliste fuers Verwaltungsmenue (Doku 3.6/7.1).
|
||||||
|
final missionSummariesProvider = StreamProvider(
|
||||||
|
(ref) => ref.watch(missionRepositoryProvider).watchSummaries(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Reaktive Drohnenprofil-Liste fuers Verwaltungsmenue (Doku 3.5/7.3).
|
||||||
|
final droneProfilesProvider = StreamProvider<List<DroneProfile>>(
|
||||||
|
(ref) => ref.watch(droneProfileRepositoryProvider).watchAll(),
|
||||||
|
);
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'current_mission_provider.dart';
|
||||||
|
import 'database_provider.dart';
|
||||||
|
|
||||||
|
/// Metadaten der aktuell in Bearbeitung befindlichen Mission - ergaenzt
|
||||||
|
/// currentMissionProvider (das nur die Wegpunkte haelt) um Name/Ort und die
|
||||||
|
/// Datenbank-id, sobald die Mission mindestens einmal gespeichert wurde.
|
||||||
|
class MissionMeta {
|
||||||
|
const MissionMeta({this.id, this.name = 'New mission', this.placeName});
|
||||||
|
|
||||||
|
/// Null, solange die Mission noch nie (auto-)gespeichert wurde.
|
||||||
|
final int? id;
|
||||||
|
final String name;
|
||||||
|
final String? placeName;
|
||||||
|
|
||||||
|
MissionMeta copyWith({int? id, String? name, String? placeName}) {
|
||||||
|
return MissionMeta(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
placeName: placeName ?? this.placeName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Haelt Name/id der aktuellen Mission und autosaved sie ueber
|
||||||
|
/// MissionRepository (Doku 7.5), sobald sich die Wegpunktliste aendert -
|
||||||
|
/// analog scheduleAutosave()/flushPendingAutosave() im HTML-Demonstrator
|
||||||
|
/// (dort 800ms debounce, hier identisch uebernommen).
|
||||||
|
class CurrentMissionMetaNotifier extends Notifier<MissionMeta> {
|
||||||
|
Timer? _autosaveTimer;
|
||||||
|
bool _suppressAutosave = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MissionMeta build() {
|
||||||
|
ref.listen(currentMissionProvider, (previous, next) {
|
||||||
|
if (!_suppressAutosave) _scheduleAutosave();
|
||||||
|
});
|
||||||
|
ref.onDispose(() => _autosaveTimer?.cancel());
|
||||||
|
return const MissionMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Laedt die zuletzt bearbeitete Mission (falls vorhanden) beim App-Start
|
||||||
|
/// wieder, damit ein Autosave-Draft einen Neustart ueberlebt (Doku 7.5).
|
||||||
|
Future<void> restoreLastSession() async {
|
||||||
|
final settings = await ref.read(appSettingsRepositoryProvider).load();
|
||||||
|
final missionId = settings.currentMissionId;
|
||||||
|
if (missionId == null) return;
|
||||||
|
final loaded = await ref.read(missionRepositoryProvider).load(missionId);
|
||||||
|
if (loaded == null) return;
|
||||||
|
|
||||||
|
_suppressAutosave = true;
|
||||||
|
ref.read(currentMissionProvider.notifier).replaceAll(loaded.waypoints);
|
||||||
|
_suppressAutosave = false;
|
||||||
|
state = MissionMeta(id: missionId, name: loaded.name, placeName: loaded.placeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Analog startNewMission() im HTML-Demonstrator: sichert zuerst noch
|
||||||
|
/// ausstehende Aenderungen der bisherigen Mission, dann leerer Neustart.
|
||||||
|
Future<void> startNew() async {
|
||||||
|
await _flushNow();
|
||||||
|
_suppressAutosave = true;
|
||||||
|
ref.read(currentMissionProvider.notifier).replaceAll(const []);
|
||||||
|
_suppressAutosave = false;
|
||||||
|
state = const MissionMeta();
|
||||||
|
await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadMission(int id) async {
|
||||||
|
await _flushNow();
|
||||||
|
final loaded = await ref.read(missionRepositoryProvider).load(id);
|
||||||
|
if (loaded == null) return;
|
||||||
|
|
||||||
|
_suppressAutosave = true;
|
||||||
|
ref.read(currentMissionProvider.notifier).replaceAll(loaded.waypoints);
|
||||||
|
_suppressAutosave = false;
|
||||||
|
state = MissionMeta(id: id, name: loaded.name, placeName: loaded.placeName);
|
||||||
|
await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Benennt eine gespeicherte Mission um (Doku 3.6: nur ueber die
|
||||||
|
/// Missionsliste moeglich, dort hat jede Zeile immer bereits eine id).
|
||||||
|
/// Aktualisiert zusaetzlich den eigenen Zustand, falls [id] die gerade
|
||||||
|
/// aktive Mission ist.
|
||||||
|
Future<void> renameMission(int id, String name) async {
|
||||||
|
await ref.read(missionRepositoryProvider).rename(id, name);
|
||||||
|
if (state.id == id) {
|
||||||
|
state = state.copyWith(name: name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleAutosave() {
|
||||||
|
if (ref.read(currentMissionProvider).isEmpty) return;
|
||||||
|
_autosaveTimer?.cancel();
|
||||||
|
_autosaveTimer = Timer(const Duration(milliseconds: 800), () {
|
||||||
|
unawaited(_flushNow());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _flushNow() async {
|
||||||
|
_autosaveTimer?.cancel();
|
||||||
|
_autosaveTimer = null;
|
||||||
|
final waypoints = ref.read(currentMissionProvider);
|
||||||
|
if (waypoints.isEmpty) return;
|
||||||
|
final id = await ref.read(missionRepositoryProvider).upsert(
|
||||||
|
id: state.id,
|
||||||
|
name: state.name,
|
||||||
|
placeName: state.placeName,
|
||||||
|
waypoints: waypoints,
|
||||||
|
);
|
||||||
|
if (state.id == null) {
|
||||||
|
state = state.copyWith(id: id);
|
||||||
|
}
|
||||||
|
await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentMissionMetaProvider =
|
||||||
|
NotifierProvider<CurrentMissionMetaNotifier, MissionMeta>(
|
||||||
|
CurrentMissionMetaNotifier.new);
|
||||||
@@ -9,8 +9,10 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import '../../../domain/mission/default_drone_profile.dart';
|
import '../../../domain/mission/default_drone_profile.dart';
|
||||||
import '../../../domain/mission/route_geometry.dart';
|
import '../../../domain/mission/route_geometry.dart';
|
||||||
import '../../../domain/waypoint/flat_waypoint_list.dart';
|
import '../../../domain/waypoint/flat_waypoint_list.dart';
|
||||||
|
import '../../providers/active_drone_profile_provider.dart';
|
||||||
import '../../providers/current_mission_provider.dart';
|
import '../../providers/current_mission_provider.dart';
|
||||||
import '../../providers/map_controller_provider.dart';
|
import '../../providers/map_controller_provider.dart';
|
||||||
|
import '../../providers/mission_meta_provider.dart';
|
||||||
import '../../providers/wind_provider.dart';
|
import '../../providers/wind_provider.dart';
|
||||||
import '../../widgets/bottom_stats_bar.dart';
|
import '../../widgets/bottom_stats_bar.dart';
|
||||||
import '../../widgets/halo_menu.dart';
|
import '../../widgets/halo_menu.dart';
|
||||||
@@ -72,8 +74,28 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
// Erstbefuellung der Kopfleisten-Wind-Pille (Doku 3.8) - erst nach dem
|
// Erstbefuellung der Kopfleisten-Wind-Pille (Doku 3.8) - erst nach dem
|
||||||
// ersten Frame, da die Kartenkamera (Fallback-Referenzort ohne
|
// ersten Frame, da die Kartenkamera (Fallback-Referenzort ohne
|
||||||
// Wegpunkte) vor dem initialen Layout noch nicht bereit ist.
|
// Wegpunkte) vor dem initialen Layout noch nicht bereit ist.
|
||||||
WidgetsBinding.instance
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
.addPostFrameCallback((_) => ref.read(headerWindProvider.notifier).refresh());
|
if (!mounted) return;
|
||||||
|
ref.read(headerWindProvider.notifier).refresh();
|
||||||
|
// Bootstrap der Persistenz (Doku 4.15/7.5): mindestens ein
|
||||||
|
// Drohnenprofil sicherstellen/laden, danach ggf. den zuletzt
|
||||||
|
// bearbeiteten Autosave-Draft wiederherstellen.
|
||||||
|
await ref.read(activeDroneProfileProvider.notifier).initialize();
|
||||||
|
await ref.read(currentMissionMetaProvider.notifier).restoreLastSession();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_curSpeed = ref.read(activeDroneProfileProvider).cruiseSpeed;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Wechselt der Nutzer waehrend der Bearbeitung das aktive Drohnenprofil,
|
||||||
|
// muss die aktuell eingestellte Soll-Geschwindigkeit in dessen
|
||||||
|
// gueltigen Bereich geklemmt werden (sonst zeigt das Drehrad einen
|
||||||
|
// Wert ausserhalb seiner eigenen Skala).
|
||||||
|
ref.listenManual(activeDroneProfileProvider, (previous, next) {
|
||||||
|
final clamped = _curSpeed.clamp(next.minSpeed, next.maxSpeed);
|
||||||
|
if (clamped != _curSpeed) setState(() => _curSpeed = clamped);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -167,7 +189,7 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
lon: center.longitude,
|
lon: center.longitude,
|
||||||
altitudeM: _curAlt,
|
altitudeM: _curAlt,
|
||||||
speedMs: _curSpeed,
|
speedMs: _curSpeed,
|
||||||
catchRadiusM: DefaultDroneProfile.catchRadiusM,
|
catchRadiusM: ref.read(activeDroneProfileProvider).catchRadius,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (waypoints.isEmpty) {
|
if (waypoints.isEmpty) {
|
||||||
@@ -325,12 +347,13 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final waypoints = ref.watch(currentMissionProvider);
|
final waypoints = ref.watch(currentMissionProvider);
|
||||||
final showPerWaypointWind = ref.watch(headerWindProvider).showPerWaypoint;
|
final showPerWaypointWind = ref.watch(headerWindProvider).showPerWaypoint;
|
||||||
|
final droneProfile = ref.watch(activeDroneProfileProvider);
|
||||||
|
|
||||||
final routeGeometry = buildRouteGeometry(
|
final routeGeometry = buildRouteGeometry(
|
||||||
waypoints,
|
waypoints,
|
||||||
minTurnRadius: DefaultDroneProfile.minTurnRadius,
|
minTurnRadius: droneProfile.minTurnRadius,
|
||||||
maxClimbRate: DefaultDroneProfile.maxClimbRate,
|
maxClimbRate: droneProfile.maxClimbRate,
|
||||||
maxDescentRate: DefaultDroneProfile.maxDescentRate,
|
maxDescentRate: droneProfile.maxDescentRate,
|
||||||
);
|
);
|
||||||
final missionStats = _missionStats(waypoints);
|
final missionStats = _missionStats(waypoints);
|
||||||
final anyBad = routeGeometry.vertexBad.any((bad) => bad) ||
|
final anyBad = routeGeometry.vertexBad.any((bad) => bad) ||
|
||||||
@@ -414,7 +437,7 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
child: ValueWheel(
|
child: ValueWheel(
|
||||||
value: _curAlt,
|
value: _curAlt,
|
||||||
min: DefaultDroneProfile.altMin,
|
min: DefaultDroneProfile.altMin,
|
||||||
max: DefaultDroneProfile.altMax,
|
max: droneProfile.maxAltitudeM,
|
||||||
step: DefaultDroneProfile.altStep,
|
step: DefaultDroneProfile.altStep,
|
||||||
pxPerStep: _altPxPerStep,
|
pxPerStep: _altPxPerStep,
|
||||||
majorEvery: _altMajorEvery,
|
majorEvery: _altMajorEvery,
|
||||||
@@ -433,8 +456,8 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
right: 14 + safeEdgeInset,
|
right: 14 + safeEdgeInset,
|
||||||
child: ValueWheel(
|
child: ValueWheel(
|
||||||
value: _curSpeed,
|
value: _curSpeed,
|
||||||
min: DefaultDroneProfile.speedMin,
|
min: droneProfile.minSpeed,
|
||||||
max: DefaultDroneProfile.speedMax,
|
max: droneProfile.maxSpeed,
|
||||||
step: DefaultDroneProfile.speedStep,
|
step: DefaultDroneProfile.speedStep,
|
||||||
pxPerStep: _speedPxPerStep,
|
pxPerStep: _speedPxPerStep,
|
||||||
majorEvery: _speedMajorEvery,
|
majorEvery: _speedMajorEvery,
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/
|
/// Statuszeile am unteren Bildschirmrand (HTML-Demonstrator: #bottomBar/
|
||||||
/// #statsRow). Antippen oeffnet die Wegpunktliste. Mission-/Drohnen-Name
|
/// #statsRow). Details-Pille (mittig) oeffnet die Wegpunktliste; die
|
||||||
/// und der Send-Button sind bewusst nicht Teil dieses Schritts - die
|
/// Mission-/Drohnen-Chips (links) oeffnen das Verwaltungsmenue mit dem
|
||||||
/// dafuer noetige Missionsverwaltung bzw. Upload-Anbindung existiert noch
|
/// passenden Starttab (HTML-Demonstrator: #missionNameBtn/#droneNameBtn).
|
||||||
/// nicht.
|
/// Der Send-Button ist bewusst nicht Teil dieses Schritts - die dafuer
|
||||||
class BottomStatsBar extends StatelessWidget {
|
/// noetige Upload-Anbindung existiert noch nicht.
|
||||||
|
class BottomStatsBar extends ConsumerWidget {
|
||||||
const BottomStatsBar({
|
const BottomStatsBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.waypointCount,
|
required this.waypointCount,
|
||||||
@@ -20,50 +26,97 @@ class BottomStatsBar extends StatelessWidget {
|
|||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@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(
|
return SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||||
child: Center(
|
child: Row(
|
||||||
child: GestureDetector(
|
children: [
|
||||||
onTap: onTap,
|
Expanded(
|
||||||
child: Container(
|
child: Align(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9),
|
alignment: Alignment.centerLeft,
|
||||||
decoration: BoxDecoration(
|
child: Row(
|
||||||
color: Colors.white.withValues(alpha: 0.12),
|
children: [
|
||||||
borderRadius: BorderRadius.circular(20),
|
Flexible(
|
||||||
),
|
child: _NameChip(
|
||||||
child: Row(
|
label: 'Mission: $missionName',
|
||||||
mainAxisSize: MainAxisSize.min,
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
children: [
|
textColor: Colors.white,
|
||||||
const Text(
|
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||||
'Details:',
|
builder: (_) => const MissionsDronesPanel.missions(),
|
||||||
style: TextStyle(
|
)),
|
||||||
color: Colors.white70,
|
),
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 6),
|
||||||
const SizedBox(width: 10),
|
Flexible(
|
||||||
const Icon(Icons.flag, size: 14, color: Colors.white),
|
child: _NameChip(
|
||||||
const SizedBox(width: 4),
|
label: 'Drone: $droneName',
|
||||||
Text('$waypointCount WP',
|
color: const Color(0xFF4D9DFF).withValues(alpha: 0.18),
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 14)),
|
textColor: const Color(0xFFCFE2FF),
|
||||||
const SizedBox(width: 14),
|
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||||
const Icon(Icons.straighten, size: 14, color: Colors.white),
|
builder: (_) => const MissionsDronesPanel.drones(),
|
||||||
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)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
_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')}';
|
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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../domain/mission/default_drone_profile.dart';
|
import '../../domain/mission/default_drone_profile.dart';
|
||||||
|
import '../../domain/mission/drone_profile.dart';
|
||||||
import '../../domain/mission/route_geometry.dart';
|
import '../../domain/mission/route_geometry.dart';
|
||||||
import '../../domain/waypoint/flat_waypoint_list.dart';
|
import '../../domain/waypoint/flat_waypoint_list.dart';
|
||||||
import '../../domain/wind/wind_math.dart';
|
import '../../domain/wind/wind_math.dart';
|
||||||
|
import '../providers/active_drone_profile_provider.dart';
|
||||||
import '../providers/current_mission_provider.dart';
|
import '../providers/current_mission_provider.dart';
|
||||||
import '../providers/wind_provider.dart';
|
import '../providers/wind_provider.dart';
|
||||||
import 'full_value_chart.dart';
|
import 'full_value_chart.dart';
|
||||||
@@ -134,11 +136,12 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final waypoints = ref.watch(currentMissionProvider);
|
final waypoints = ref.watch(currentMissionProvider);
|
||||||
|
final droneProfile = ref.watch(activeDroneProfileProvider);
|
||||||
final routeGeometry = buildRouteGeometry(
|
final routeGeometry = buildRouteGeometry(
|
||||||
waypoints,
|
waypoints,
|
||||||
minTurnRadius: DefaultDroneProfile.minTurnRadius,
|
minTurnRadius: droneProfile.minTurnRadius,
|
||||||
maxClimbRate: DefaultDroneProfile.maxClimbRate,
|
maxClimbRate: droneProfile.maxClimbRate,
|
||||||
maxDescentRate: DefaultDroneProfile.maxDescentRate,
|
maxDescentRate: droneProfile.maxDescentRate,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
@@ -190,13 +193,13 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
const Divider(color: Colors.white24, height: 1),
|
const Divider(color: Colors.white24, height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: switch (_tab) {
|
child: switch (_tab) {
|
||||||
_PanelTab.list => _listTab(waypoints, routeGeometry),
|
_PanelTab.list => _listTab(waypoints, routeGeometry, droneProfile),
|
||||||
_PanelTab.altitude => _chartTab(
|
_PanelTab.altitude => _chartTab(
|
||||||
waypoints,
|
waypoints,
|
||||||
routeGeometry,
|
routeGeometry,
|
||||||
values: [for (final w in waypoints) w.altitudeM],
|
values: [for (final w in waypoints) w.altitudeM],
|
||||||
min: DefaultDroneProfile.altMin,
|
min: DefaultDroneProfile.altMin,
|
||||||
max: DefaultDroneProfile.altMax,
|
max: droneProfile.maxAltitudeM,
|
||||||
step: DefaultDroneProfile.altStep,
|
step: DefaultDroneProfile.altStep,
|
||||||
pxPerUnit: 5,
|
pxPerUnit: 5,
|
||||||
unit: ' m',
|
unit: ' m',
|
||||||
@@ -206,8 +209,8 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
waypoints,
|
waypoints,
|
||||||
routeGeometry,
|
routeGeometry,
|
||||||
values: [for (final w in waypoints) w.speedMs],
|
values: [for (final w in waypoints) w.speedMs],
|
||||||
min: DefaultDroneProfile.speedMin,
|
min: droneProfile.minSpeed,
|
||||||
max: DefaultDroneProfile.speedMax,
|
max: droneProfile.maxSpeed,
|
||||||
step: DefaultDroneProfile.speedStep,
|
step: DefaultDroneProfile.speedStep,
|
||||||
pxPerUnit: 22,
|
pxPerUnit: 22,
|
||||||
unit: ' m/s',
|
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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
if (waypoints.isNotEmpty)
|
if (waypoints.isNotEmpty)
|
||||||
@@ -322,14 +329,19 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
itemExtent: _rowExtent,
|
itemExtent: _rowExtent,
|
||||||
itemCount: waypoints.length,
|
itemCount: waypoints.length,
|
||||||
itemBuilder: (context, index) =>
|
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 isEditing = _rowEditIndex == index;
|
||||||
final isActive = widget.activeIndex == index;
|
final isActive = widget.activeIndex == index;
|
||||||
final isBad = index < routeGeometry.vertexBad.length &&
|
final isBad = index < routeGeometry.vertexBad.length &&
|
||||||
@@ -371,7 +383,7 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
value: wp.altitudeM,
|
value: wp.altitudeM,
|
||||||
unit: ' m',
|
unit: ' m',
|
||||||
min: DefaultDroneProfile.altMin,
|
min: DefaultDroneProfile.altMin,
|
||||||
max: DefaultDroneProfile.altMax,
|
max: droneProfile.maxAltitudeM,
|
||||||
step: DefaultDroneProfile.altStep,
|
step: DefaultDroneProfile.altStep,
|
||||||
onChanged: (v) => widget.onAltitudeChanged(index, v),
|
onChanged: (v) => widget.onAltitudeChanged(index, v),
|
||||||
)
|
)
|
||||||
@@ -383,8 +395,8 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
? WaypointChip(
|
? WaypointChip(
|
||||||
value: wp.speedMs,
|
value: wp.speedMs,
|
||||||
unit: ' m/s',
|
unit: ' m/s',
|
||||||
min: DefaultDroneProfile.speedMin,
|
min: droneProfile.minSpeed,
|
||||||
max: DefaultDroneProfile.speedMax,
|
max: droneProfile.maxSpeed,
|
||||||
step: DefaultDroneProfile.speedStep,
|
step: DefaultDroneProfile.speedStep,
|
||||||
onChanged: (v) => widget.onSpeedChanged(index, v),
|
onChanged: (v) => widget.onSpeedChanged(index, v),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:dmc_app/services/database/app_database.dart';
|
||||||
|
import 'package:dmc_app/services/database/app_settings_repository.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late AppSettingsRepository repository;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
repository = AppSettingsRepository(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
test('load ohne vorherige Werte liefert beides null', () async {
|
||||||
|
final settings = await repository.load();
|
||||||
|
expect(settings.activeDroneProfileId, isNull);
|
||||||
|
expect(settings.currentMissionId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setActiveDroneProfileId und setCurrentMissionId sind unabhaengig voneinander',
|
||||||
|
() async {
|
||||||
|
await repository.setActiveDroneProfileId(3);
|
||||||
|
await repository.setCurrentMissionId(7);
|
||||||
|
|
||||||
|
final settings = await repository.load();
|
||||||
|
expect(settings.activeDroneProfileId, 3);
|
||||||
|
expect(settings.currentMissionId, 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('erneutes Setzen eines Werts laesst den anderen unangetastet', () async {
|
||||||
|
await repository.setActiveDroneProfileId(1);
|
||||||
|
await repository.setCurrentMissionId(2);
|
||||||
|
|
||||||
|
await repository.setCurrentMissionId(9);
|
||||||
|
|
||||||
|
final settings = await repository.load();
|
||||||
|
expect(settings.activeDroneProfileId, 1);
|
||||||
|
expect(settings.currentMissionId, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Wert kann auf null zurueckgesetzt werden', () async {
|
||||||
|
await repository.setCurrentMissionId(5);
|
||||||
|
await repository.setCurrentMissionId(null);
|
||||||
|
|
||||||
|
final settings = await repository.load();
|
||||||
|
expect(settings.currentMissionId, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:dmc_app/domain/mission/drone_profile.dart';
|
||||||
|
import 'package:dmc_app/services/database/app_database.dart' hide DroneProfile;
|
||||||
|
import 'package:dmc_app/services/database/drone_profile_repository.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late DroneProfileRepository repository;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
repository = DroneProfileRepository(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
test('ensureSeeded legt beim ersten Aufruf T1 Ranger an', () async {
|
||||||
|
final id = await repository.ensureSeeded();
|
||||||
|
|
||||||
|
final all = await repository.loadAll();
|
||||||
|
expect(all, hasLength(1));
|
||||||
|
expect(all.single.id, id);
|
||||||
|
expect(all.single.name, 'T1 Ranger');
|
||||||
|
expect(all.single.cruiseSpeed, 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureSeeded ist ein no-op, wenn bereits Profile existieren', () async {
|
||||||
|
await repository.insert(DroneProfile.t1Ranger().copyWith(name: 'Custom'));
|
||||||
|
|
||||||
|
await repository.ensureSeeded();
|
||||||
|
|
||||||
|
final all = await repository.loadAll();
|
||||||
|
expect(all, hasLength(1));
|
||||||
|
expect(all.single.name, 'Custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('insert/update/delete rundtrip', () async {
|
||||||
|
final id = await repository.insert(
|
||||||
|
DroneProfile.t1Ranger().copyWith(name: 'Racer', maxSpeed: 40),
|
||||||
|
);
|
||||||
|
|
||||||
|
var all = await repository.loadAll();
|
||||||
|
expect(all.single.maxSpeed, 40);
|
||||||
|
|
||||||
|
await repository.update(all.single.copyWith(maxSpeed: 45));
|
||||||
|
all = await repository.loadAll();
|
||||||
|
expect(all.single.maxSpeed, 45);
|
||||||
|
expect(all.single.id, id);
|
||||||
|
|
||||||
|
await repository.delete(id);
|
||||||
|
expect(await repository.loadAll(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('watchAll gibt Aenderungen reaktiv weiter', () async {
|
||||||
|
final stream = repository.watchAll();
|
||||||
|
final emissions = <int>[];
|
||||||
|
final sub = stream.listen((profiles) => emissions.add(profiles.length));
|
||||||
|
addTearDown(sub.cancel);
|
||||||
|
|
||||||
|
await repository.insert(DroneProfile.t1Ranger());
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(emissions, contains(1));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
|
||||||
|
import 'package:dmc_app/services/database/app_database.dart';
|
||||||
|
import 'package:dmc_app/services/database/mission_repository.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late MissionRepository repository;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
repository = MissionRepository(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
const waypoints = [
|
||||||
|
Waypoint(lat: 52.0, lon: 4.0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
|
||||||
|
Waypoint(
|
||||||
|
lat: 52.01,
|
||||||
|
lon: 4.01,
|
||||||
|
altitudeM: 80,
|
||||||
|
speedMs: 18,
|
||||||
|
catchRadiusM: 60,
|
||||||
|
action: WaypointAction.loiter,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
test('upsert ohne id legt eine neue Mission an', () async {
|
||||||
|
final id = await repository.upsert(
|
||||||
|
name: 'Amsterdam',
|
||||||
|
placeName: 'Amsterdam',
|
||||||
|
waypoints: waypoints,
|
||||||
|
);
|
||||||
|
|
||||||
|
final loaded = await repository.load(id);
|
||||||
|
expect(loaded, isNotNull);
|
||||||
|
expect(loaded!.name, 'Amsterdam');
|
||||||
|
expect(loaded.placeName, 'Amsterdam');
|
||||||
|
expect(loaded.waypoints, hasLength(2));
|
||||||
|
expect(loaded.waypoints[1].action, WaypointAction.loiter);
|
||||||
|
expect(loaded.waypoints[1].speedMs, 18);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upsert mit bestehender id ueberschreibt statt zu duplizieren', () async {
|
||||||
|
final id = await repository.upsert(name: 'Draft', waypoints: waypoints);
|
||||||
|
|
||||||
|
await repository.upsert(
|
||||||
|
id: id,
|
||||||
|
name: 'Draft',
|
||||||
|
waypoints: [waypoints.first],
|
||||||
|
);
|
||||||
|
|
||||||
|
final summaries = await repository.watchSummaries().first;
|
||||||
|
expect(summaries, hasLength(1));
|
||||||
|
expect(summaries.single.waypointCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rename aendert nur den Namen, nicht die Wegpunkte', () async {
|
||||||
|
final id = await repository.upsert(name: 'Old name', waypoints: waypoints);
|
||||||
|
|
||||||
|
await repository.rename(id, 'New name');
|
||||||
|
|
||||||
|
final loaded = await repository.load(id);
|
||||||
|
expect(loaded!.name, 'New name');
|
||||||
|
expect(loaded.waypoints, hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete entfernt die Mission aus der Liste', () async {
|
||||||
|
final id = await repository.upsert(name: 'To delete', waypoints: waypoints);
|
||||||
|
|
||||||
|
await repository.delete(id);
|
||||||
|
|
||||||
|
final loaded = await repository.load(id);
|
||||||
|
expect(loaded, isNull);
|
||||||
|
expect(await repository.watchSummaries().first, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('load fuer unbekannte id liefert null', () async {
|
||||||
|
expect(await repository.load(999), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Windfelder werden mitgespeichert und wiederhergestellt', () async {
|
||||||
|
const withWind = Waypoint(
|
||||||
|
lat: 52.0,
|
||||||
|
lon: 4.0,
|
||||||
|
altitudeM: 60,
|
||||||
|
speedMs: 15,
|
||||||
|
catchRadiusM: 60,
|
||||||
|
windSpeedMs: 5.5,
|
||||||
|
windDirFromDeg: 210,
|
||||||
|
windElevationM: 2,
|
||||||
|
);
|
||||||
|
final id = await repository.upsert(name: 'Windy', waypoints: [withWind]);
|
||||||
|
|
||||||
|
final loaded = await repository.load(id);
|
||||||
|
|
||||||
|
expect(loaded!.waypoints.single.windSpeedMs, 5.5);
|
||||||
|
expect(loaded.waypoints.single.windDirFromDeg, 210);
|
||||||
|
expect(loaded.waypoints.single.windElevationM, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
@@ -6,8 +7,10 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
|
|
||||||
import 'package:dmc_app/app_mode/app_mode_cubit.dart';
|
import 'package:dmc_app/app_mode/app_mode_cubit.dart';
|
||||||
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
|
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
|
||||||
|
import 'package:dmc_app/services/database/app_database.dart' hide DroneProfile, Mission;
|
||||||
import 'package:dmc_app/ui/app_shell.dart';
|
import 'package:dmc_app/ui/app_shell.dart';
|
||||||
import 'package:dmc_app/ui/providers/current_mission_provider.dart';
|
import 'package:dmc_app/ui/providers/current_mission_provider.dart';
|
||||||
|
import 'package:dmc_app/ui/providers/database_provider.dart';
|
||||||
import 'package:dmc_app/ui/widgets/full_value_chart.dart';
|
import 'package:dmc_app/ui/widgets/full_value_chart.dart';
|
||||||
import 'package:dmc_app/ui/widgets/halo_menu.dart';
|
import 'package:dmc_app/ui/widgets/halo_menu.dart';
|
||||||
import 'package:dmc_app/ui/widgets/header_wind_pill.dart';
|
import 'package:dmc_app/ui/widgets/header_wind_pill.dart';
|
||||||
@@ -19,7 +22,18 @@ import 'package:dmc_app/ui/widgets/waypoint_list_panel.dart';
|
|||||||
import 'package:dmc_app/ui/widgets/wind_marker_pill.dart';
|
import 'package:dmc_app/ui/widgets/wind_marker_pill.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
// AppShell/PlanScreen initialisieren beim Start die Drift-Datenbank (Doku
|
||||||
|
// 4.15/7.5: Drohnenprofil-Bootstrap, Autosave-Restore) - in Widget-Tests
|
||||||
|
// durch eine In-Memory-Instanz ersetzt, damit dafuer nicht das
|
||||||
|
// path_provider-Plugin (echtes Dateisystem) benoetigt wird.
|
||||||
|
AppDatabase _testDatabase() => AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
|
||||||
|
ProviderContainer _testContainer() => ProviderContainer(
|
||||||
|
overrides: [appDatabaseProvider.overrideWithValue(_testDatabase())],
|
||||||
|
);
|
||||||
|
|
||||||
Widget _wrap(Widget child) => ProviderScope(
|
Widget _wrap(Widget child) => ProviderScope(
|
||||||
|
overrides: [appDatabaseProvider.overrideWithValue(_testDatabase())],
|
||||||
child: BlocProvider(
|
child: BlocProvider(
|
||||||
create: (_) => AppModeCubit(),
|
create: (_) => AppModeCubit(),
|
||||||
child: MaterialApp(home: child),
|
child: MaterialApp(home: child),
|
||||||
@@ -27,7 +41,7 @@ Widget _wrap(Widget child) => ProviderScope(
|
|||||||
);
|
);
|
||||||
|
|
||||||
Future<ProviderContainer> _pumpWithContainer(WidgetTester tester) async {
|
Future<ProviderContainer> _pumpWithContainer(WidgetTester tester) async {
|
||||||
final container = ProviderContainer();
|
final container = _testContainer();
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
UncontrolledProviderScope(
|
UncontrolledProviderScope(
|
||||||
container: container,
|
container: container,
|
||||||
@@ -73,7 +87,7 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('Reticle-Button fuegt einen Wegpunkt am Kartenzentrum hinzu',
|
testWidgets('Reticle-Button fuegt einen Wegpunkt am Kartenzentrum hinzu',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final container = ProviderContainer();
|
final container = _testContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -96,12 +110,16 @@ void main() {
|
|||||||
expect(waypoints, hasLength(1));
|
expect(waypoints, hasLength(1));
|
||||||
expect(waypoints.single.lat, closeTo(52.3676, 0.0001));
|
expect(waypoints.single.lat, closeTo(52.3676, 0.0001));
|
||||||
expect(waypoints.single.lon, closeTo(4.9041, 0.0001));
|
expect(waypoints.single.lon, closeTo(4.9041, 0.0001));
|
||||||
|
|
||||||
|
// Autosave debounced ueber 800ms - abwarten, damit nach Testende kein
|
||||||
|
// Timer mehr aussteht (Doku 7.5).
|
||||||
|
await tester.pump(const Duration(milliseconds: 900));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'Zweiter Tap auf den frisch gesetzten Wegpunkt oeffnet das Halo-Menue',
|
'Zweiter Tap auf den frisch gesetzten Wegpunkt oeffnet das Halo-Menue',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final container = ProviderContainer();
|
final container = _testContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -124,11 +142,15 @@ void main() {
|
|||||||
await tester.tap(find.byType(ReticleButton));
|
await tester.tap(find.byType(ReticleButton));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.byType(HaloMenu), findsOneWidget);
|
expect(find.byType(HaloMenu), findsOneWidget);
|
||||||
|
|
||||||
|
// Autosave debounced ueber 800ms - abwarten, damit nach Testende kein
|
||||||
|
// Timer mehr aussteht (Doku 7.5).
|
||||||
|
await tester.pump(const Duration(milliseconds: 900));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Halo-Remove entfernt den Wegpunkt und schliesst das Menue',
|
testWidgets('Halo-Remove entfernt den Wegpunkt und schliesst das Menue',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final container = ProviderContainer();
|
final container = _testContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -155,11 +177,15 @@ void main() {
|
|||||||
|
|
||||||
expect(container.read(currentMissionProvider), isEmpty);
|
expect(container.read(currentMissionProvider), isEmpty);
|
||||||
expect(find.byType(HaloMenu), findsNothing);
|
expect(find.byType(HaloMenu), findsNothing);
|
||||||
|
|
||||||
|
// Autosave debounced ueber 800ms - abwarten, damit nach Testende kein
|
||||||
|
// Timer mehr aussteht (Doku 7.5).
|
||||||
|
await tester.pump(const Duration(milliseconds: 900));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Halo-Loiter setzt die Execute-Aktion und schliesst das Menue',
|
testWidgets('Halo-Loiter setzt die Execute-Aktion und schliesst das Menue',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final container = ProviderContainer();
|
final container = _testContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -184,6 +210,10 @@ void main() {
|
|||||||
final waypoints = container.read(currentMissionProvider);
|
final waypoints = container.read(currentMissionProvider);
|
||||||
expect(waypoints.single.action, WaypointAction.loiter);
|
expect(waypoints.single.action, WaypointAction.loiter);
|
||||||
expect(find.byType(HaloMenu), findsNothing);
|
expect(find.byType(HaloMenu), findsNothing);
|
||||||
|
|
||||||
|
// Autosave debounced ueber 800ms - abwarten, damit nach Testende kein
|
||||||
|
// Timer mehr aussteht (Doku 7.5).
|
||||||
|
await tester.pump(const Duration(milliseconds: 900));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Bottom-Stats-Bar oeffnet die leere Wegpunktliste',
|
testWidgets('Bottom-Stats-Bar oeffnet die leere Wegpunktliste',
|
||||||
@@ -219,6 +249,10 @@ void main() {
|
|||||||
await tester.pump(const Duration(milliseconds: 200));
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
expect(find.byType(WaypointListPanel), findsNothing);
|
expect(find.byType(WaypointListPanel), findsNothing);
|
||||||
|
|
||||||
|
// Autosave debounced ueber 800ms - abwarten, damit nach Testende kein
|
||||||
|
// Timer mehr aussteht (Doku 7.5).
|
||||||
|
await tester.pump(const Duration(milliseconds: 900));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Papierkorb-Icon in der Liste entfernt den Wegpunkt',
|
testWidgets('Papierkorb-Icon in der Liste entfernt den Wegpunkt',
|
||||||
@@ -238,6 +272,10 @@ void main() {
|
|||||||
|
|
||||||
expect(container.read(currentMissionProvider), isEmpty);
|
expect(container.read(currentMissionProvider), isEmpty);
|
||||||
expect(find.text('No waypoints yet'), findsOneWidget);
|
expect(find.text('No waypoints yet'), findsOneWidget);
|
||||||
|
|
||||||
|
// Autosave debounced ueber 800ms - abwarten, damit nach Testende kein
|
||||||
|
// Timer mehr aussteht (Doku 7.5).
|
||||||
|
await tester.pump(const Duration(milliseconds: 900));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@@ -267,6 +305,10 @@ void main() {
|
|||||||
|
|
||||||
final waypoints = container.read(currentMissionProvider);
|
final waypoints = container.read(currentMissionProvider);
|
||||||
expect(waypoints.single.altitudeM, greaterThan(60));
|
expect(waypoints.single.altitudeM, greaterThan(60));
|
||||||
|
|
||||||
|
// Autosave debounced ueber 800ms - abwarten, damit nach Testende kein
|
||||||
|
// Timer mehr aussteht (Doku 7.5).
|
||||||
|
await tester.pump(const Duration(milliseconds: 900));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@@ -298,6 +340,10 @@ void main() {
|
|||||||
final speed = container.read(currentMissionProvider).single.speedMs;
|
final speed = container.read(currentMissionProvider).single.speedMs;
|
||||||
expect(speed, greaterThan(startSpeed));
|
expect(speed, greaterThan(startSpeed));
|
||||||
expect(speed, lessThan(startSpeed + 4));
|
expect(speed, lessThan(startSpeed + 4));
|
||||||
|
|
||||||
|
// Autosave debounced ueber 800ms - abwarten, damit nach Testende kein
|
||||||
|
// Timer mehr aussteht (Doku 7.5).
|
||||||
|
await tester.pump(const Duration(milliseconds: 900));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@@ -321,6 +367,10 @@ void main() {
|
|||||||
|
|
||||||
final waypoints = container.read(currentMissionProvider);
|
final waypoints = container.read(currentMissionProvider);
|
||||||
expect(waypoints.single.altitudeM, greaterThan(60));
|
expect(waypoints.single.altitudeM, greaterThan(60));
|
||||||
|
|
||||||
|
// Autosave debounced ueber 800ms - abwarten, damit nach Testende kein
|
||||||
|
// Timer mehr aussteht (Doku 7.5).
|
||||||
|
await tester.pump(const Duration(milliseconds: 900));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@@ -344,6 +394,10 @@ void main() {
|
|||||||
|
|
||||||
final waypoints = container.read(currentMissionProvider);
|
final waypoints = container.read(currentMissionProvider);
|
||||||
expect(waypoints.single.speedMs, greaterThan(15));
|
expect(waypoints.single.speedMs, greaterThan(15));
|
||||||
|
|
||||||
|
// Autosave debounced ueber 800ms - abwarten, damit nach Testende kein
|
||||||
|
// Timer mehr aussteht (Doku 7.5).
|
||||||
|
await tester.pump(const Duration(milliseconds: 900));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('MapSearchControls zeigt Suchfeld und Home-/Fit-Buttons',
|
testWidgets('MapSearchControls zeigt Suchfeld und Home-/Fit-Buttons',
|
||||||
@@ -391,7 +445,7 @@ void main() {
|
|||||||
testWidgets(
|
testWidgets(
|
||||||
'Kameraposition bleibt beim Wechsel Plan -> Fly -> Plan erhalten',
|
'Kameraposition bleibt beim Wechsel Plan -> Fly -> Plan erhalten',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final container = ProviderContainer();
|
final container = _testContainer();
|
||||||
final appModeCubit = AppModeCubit();
|
final appModeCubit = AppModeCubit();
|
||||||
addTearDown(() {
|
addTearDown(() {
|
||||||
container.dispose();
|
container.dispose();
|
||||||
@@ -434,7 +488,7 @@ void main() {
|
|||||||
testWidgets(
|
testWidgets(
|
||||||
'Wegpunktliste scrollt beim Oeffnen automatisch zum aktiven Wegpunkt',
|
'Wegpunktliste scrollt beim Oeffnen automatisch zum aktiven Wegpunkt',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final container = ProviderContainer();
|
final container = _testContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
final notifier = container.read(currentMissionProvider.notifier);
|
final notifier = container.read(currentMissionProvider.notifier);
|
||||||
@@ -476,7 +530,7 @@ void main() {
|
|||||||
testWidgets(
|
testWidgets(
|
||||||
'Wind-Marker an Wegpunkten erscheinen erst nach Antippen der Kopfleisten-Pille',
|
'Wind-Marker an Wegpunkten erscheinen erst nach Antippen der Kopfleisten-Pille',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final container = ProviderContainer();
|
final container = _testContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
// Am Kartenzentrum platziert (siehe MissionMap._initialCenter), damit
|
// Am Kartenzentrum platziert (siehe MissionMap._initialCenter), damit
|
||||||
|
|||||||
Reference in New Issue
Block a user