Persist the ground elevation profile with the mission

The Mini-Altitude-Profile terrain data only ever lived in TerrainNotifier's
in-memory state (one slot, keyed by route). Every app restart, or every
switch away from and back to a mission, forced a full re-fetch of all
AWS Terrarium elevation tiles for the route - the noticeably slow load
the user reported for some missions was this happening on every visit,
not just once.

Adds a nullable terrainProfileJson column to the missions table
(schema v6->v7) and a saveTerrainProfile() repository method, kept
separate from the regular waypoint upsert() so an ordinary autosave never
clobbers an already-cached profile. TerrainNotifier persists a profile
right after a successful fetch (fire-and-forget) and gains restore(),
called from CurrentMissionMetaNotifier whenever a mission is loaded/
started so a previously fetched profile is available immediately -
ensureFor() still validates its routeKey before use, so a stale restored
profile is never shown for a route that has since changed.

Also included in mission export/import (MissionExportData/
ParsedMissionImport) so sharing a mission carries its terrain cache along
instead of forcing the recipient to refetch it.

Sample-count/resolution stays as-is for now (still fixed 30m spacing,
10-2000 samples) - adapting the sampling density to terrain variance
(e.g. coarser sampling over flat ground) is a separate follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Constantin Leue
2026-08-04 08:10:41 +02:00
co-authored by Claude Sonnet 5
parent 4e94102861
commit 35351140ae
11 changed files with 315 additions and 9 deletions
+12 -1
View File
@@ -24,6 +24,14 @@ class Missions extends Table {
TextColumn get waypointsJson => text()();
BoolColumn get isDirty => boolean().withDefault(const Constant(true))();
TextColumn get serverId => text().nullable()();
/// Gecachtes Bodenhoehenprofil (Doku 3.9), an einen routeKeyForTerrain()
/// gebunden (siehe TerrainProfile) - null, solange fuer die aktuelle Route
/// noch keins abgerufen wurde. Persistiert statt nur im TerrainNotifier
/// im Speicher gehalten, damit ein erneutes Oeffnen derselben Mission
/// (oder ein Import auf einem anderen Geraet) nicht jedes Mal alle
/// AWS-Terrarium-Kacheln neu laden muss.
TextColumn get terrainProfileJson => text().nullable()();
}
/// Drohnen-Profile (Doku 3.5/7.3) - FC-Typ bewusst nicht Teil des Schemas,
@@ -102,7 +110,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor);
@override
int get schemaVersion => 6;
int get schemaVersion => 7;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -146,6 +154,9 @@ class AppDatabase extends _$AppDatabase {
appSettingsTable.connectionWifiLastSsid,
);
}
if (from < 7) {
await m.addColumn(missions, missions.terrainProfileJson);
}
},
);
+90 -3
View File
@@ -102,6 +102,17 @@ class $MissionsTable extends Missions with TableInfo<$MissionsTable, Mission> {
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _terrainProfileJsonMeta =
const VerificationMeta('terrainProfileJson');
@override
late final GeneratedColumn<String> terrainProfileJson =
GeneratedColumn<String>(
'terrain_profile_json',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
@override
List<GeneratedColumn> get $columns => [
id,
@@ -112,6 +123,7 @@ class $MissionsTable extends Missions with TableInfo<$MissionsTable, Mission> {
waypointsJson,
isDirty,
serverId,
terrainProfileJson,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -177,6 +189,15 @@ class $MissionsTable extends Missions with TableInfo<$MissionsTable, Mission> {
serverId.isAcceptableOrUnknown(data['server_id']!, _serverIdMeta),
);
}
if (data.containsKey('terrain_profile_json')) {
context.handle(
_terrainProfileJsonMeta,
terrainProfileJson.isAcceptableOrUnknown(
data['terrain_profile_json']!,
_terrainProfileJsonMeta,
),
);
}
return context;
}
@@ -218,6 +239,10 @@ class $MissionsTable extends Missions with TableInfo<$MissionsTable, Mission> {
DriftSqlType.string,
data['${effectivePrefix}server_id'],
),
terrainProfileJson: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}terrain_profile_json'],
),
);
}
@@ -236,6 +261,14 @@ class Mission extends DataClass implements Insertable<Mission> {
final String waypointsJson;
final bool isDirty;
final String? serverId;
/// Gecachtes Bodenhoehenprofil (Doku 3.9), an einen routeKeyForTerrain()
/// gebunden (siehe TerrainProfile) - null, solange fuer die aktuelle Route
/// noch keins abgerufen wurde. Persistiert statt nur im TerrainNotifier
/// im Speicher gehalten, damit ein erneutes Oeffnen derselben Mission
/// (oder ein Import auf einem anderen Geraet) nicht jedes Mal alle
/// AWS-Terrarium-Kacheln neu laden muss.
final String? terrainProfileJson;
const Mission({
required this.id,
required this.name,
@@ -245,6 +278,7 @@ class Mission extends DataClass implements Insertable<Mission> {
required this.waypointsJson,
required this.isDirty,
this.serverId,
this.terrainProfileJson,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -261,6 +295,9 @@ class Mission extends DataClass implements Insertable<Mission> {
if (!nullToAbsent || serverId != null) {
map['server_id'] = Variable<String>(serverId);
}
if (!nullToAbsent || terrainProfileJson != null) {
map['terrain_profile_json'] = Variable<String>(terrainProfileJson);
}
return map;
}
@@ -278,6 +315,9 @@ class Mission extends DataClass implements Insertable<Mission> {
serverId: serverId == null && nullToAbsent
? const Value.absent()
: Value(serverId),
terrainProfileJson: terrainProfileJson == null && nullToAbsent
? const Value.absent()
: Value(terrainProfileJson),
);
}
@@ -295,6 +335,9 @@ class Mission extends DataClass implements Insertable<Mission> {
waypointsJson: serializer.fromJson<String>(json['waypointsJson']),
isDirty: serializer.fromJson<bool>(json['isDirty']),
serverId: serializer.fromJson<String?>(json['serverId']),
terrainProfileJson: serializer.fromJson<String?>(
json['terrainProfileJson'],
),
);
}
@override
@@ -309,6 +352,7 @@ class Mission extends DataClass implements Insertable<Mission> {
'waypointsJson': serializer.toJson<String>(waypointsJson),
'isDirty': serializer.toJson<bool>(isDirty),
'serverId': serializer.toJson<String?>(serverId),
'terrainProfileJson': serializer.toJson<String?>(terrainProfileJson),
};
}
@@ -321,6 +365,7 @@ class Mission extends DataClass implements Insertable<Mission> {
String? waypointsJson,
bool? isDirty,
Value<String?> serverId = const Value.absent(),
Value<String?> terrainProfileJson = const Value.absent(),
}) => Mission(
id: id ?? this.id,
name: name ?? this.name,
@@ -330,6 +375,9 @@ class Mission extends DataClass implements Insertable<Mission> {
waypointsJson: waypointsJson ?? this.waypointsJson,
isDirty: isDirty ?? this.isDirty,
serverId: serverId.present ? serverId.value : this.serverId,
terrainProfileJson: terrainProfileJson.present
? terrainProfileJson.value
: this.terrainProfileJson,
);
Mission copyWithCompanion(MissionsCompanion data) {
return Mission(
@@ -343,6 +391,9 @@ class Mission extends DataClass implements Insertable<Mission> {
: this.waypointsJson,
isDirty: data.isDirty.present ? data.isDirty.value : this.isDirty,
serverId: data.serverId.present ? data.serverId.value : this.serverId,
terrainProfileJson: data.terrainProfileJson.present
? data.terrainProfileJson.value
: this.terrainProfileJson,
);
}
@@ -356,7 +407,8 @@ class Mission extends DataClass implements Insertable<Mission> {
..write('updatedAt: $updatedAt, ')
..write('waypointsJson: $waypointsJson, ')
..write('isDirty: $isDirty, ')
..write('serverId: $serverId')
..write('serverId: $serverId, ')
..write('terrainProfileJson: $terrainProfileJson')
..write(')'))
.toString();
}
@@ -371,6 +423,7 @@ class Mission extends DataClass implements Insertable<Mission> {
waypointsJson,
isDirty,
serverId,
terrainProfileJson,
);
@override
bool operator ==(Object other) =>
@@ -383,7 +436,8 @@ class Mission extends DataClass implements Insertable<Mission> {
other.updatedAt == this.updatedAt &&
other.waypointsJson == this.waypointsJson &&
other.isDirty == this.isDirty &&
other.serverId == this.serverId);
other.serverId == this.serverId &&
other.terrainProfileJson == this.terrainProfileJson);
}
class MissionsCompanion extends UpdateCompanion<Mission> {
@@ -395,6 +449,7 @@ class MissionsCompanion extends UpdateCompanion<Mission> {
final Value<String> waypointsJson;
final Value<bool> isDirty;
final Value<String?> serverId;
final Value<String?> terrainProfileJson;
const MissionsCompanion({
this.id = const Value.absent(),
this.name = const Value.absent(),
@@ -404,6 +459,7 @@ class MissionsCompanion extends UpdateCompanion<Mission> {
this.waypointsJson = const Value.absent(),
this.isDirty = const Value.absent(),
this.serverId = const Value.absent(),
this.terrainProfileJson = const Value.absent(),
});
MissionsCompanion.insert({
this.id = const Value.absent(),
@@ -414,6 +470,7 @@ class MissionsCompanion extends UpdateCompanion<Mission> {
required String waypointsJson,
this.isDirty = const Value.absent(),
this.serverId = const Value.absent(),
this.terrainProfileJson = const Value.absent(),
}) : name = Value(name),
waypointsJson = Value(waypointsJson);
static Insertable<Mission> custom({
@@ -425,6 +482,7 @@ class MissionsCompanion extends UpdateCompanion<Mission> {
Expression<String>? waypointsJson,
Expression<bool>? isDirty,
Expression<String>? serverId,
Expression<String>? terrainProfileJson,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
@@ -435,6 +493,8 @@ class MissionsCompanion extends UpdateCompanion<Mission> {
if (waypointsJson != null) 'waypoints_json': waypointsJson,
if (isDirty != null) 'is_dirty': isDirty,
if (serverId != null) 'server_id': serverId,
if (terrainProfileJson != null)
'terrain_profile_json': terrainProfileJson,
});
}
@@ -447,6 +507,7 @@ class MissionsCompanion extends UpdateCompanion<Mission> {
Value<String>? waypointsJson,
Value<bool>? isDirty,
Value<String?>? serverId,
Value<String?>? terrainProfileJson,
}) {
return MissionsCompanion(
id: id ?? this.id,
@@ -457,6 +518,7 @@ class MissionsCompanion extends UpdateCompanion<Mission> {
waypointsJson: waypointsJson ?? this.waypointsJson,
isDirty: isDirty ?? this.isDirty,
serverId: serverId ?? this.serverId,
terrainProfileJson: terrainProfileJson ?? this.terrainProfileJson,
);
}
@@ -487,6 +549,9 @@ class MissionsCompanion extends UpdateCompanion<Mission> {
if (serverId.present) {
map['server_id'] = Variable<String>(serverId.value);
}
if (terrainProfileJson.present) {
map['terrain_profile_json'] = Variable<String>(terrainProfileJson.value);
}
return map;
}
@@ -500,7 +565,8 @@ class MissionsCompanion extends UpdateCompanion<Mission> {
..write('updatedAt: $updatedAt, ')
..write('waypointsJson: $waypointsJson, ')
..write('isDirty: $isDirty, ')
..write('serverId: $serverId')
..write('serverId: $serverId, ')
..write('terrainProfileJson: $terrainProfileJson')
..write(')'))
.toString();
}
@@ -2036,6 +2102,7 @@ typedef $$MissionsTableCreateCompanionBuilder =
required String waypointsJson,
Value<bool> isDirty,
Value<String?> serverId,
Value<String?> terrainProfileJson,
});
typedef $$MissionsTableUpdateCompanionBuilder =
MissionsCompanion Function({
@@ -2047,6 +2114,7 @@ typedef $$MissionsTableUpdateCompanionBuilder =
Value<String> waypointsJson,
Value<bool> isDirty,
Value<String?> serverId,
Value<String?> terrainProfileJson,
});
class $$MissionsTableFilterComposer
@@ -2097,6 +2165,11 @@ class $$MissionsTableFilterComposer
column: $table.serverId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get terrainProfileJson => $composableBuilder(
column: $table.terrainProfileJson,
builder: (column) => ColumnFilters(column),
);
}
class $$MissionsTableOrderingComposer
@@ -2147,6 +2220,11 @@ class $$MissionsTableOrderingComposer
column: $table.serverId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get terrainProfileJson => $composableBuilder(
column: $table.terrainProfileJson,
builder: (column) => ColumnOrderings(column),
);
}
class $$MissionsTableAnnotationComposer
@@ -2183,6 +2261,11 @@ class $$MissionsTableAnnotationComposer
GeneratedColumn<String> get serverId =>
$composableBuilder(column: $table.serverId, builder: (column) => column);
GeneratedColumn<String> get terrainProfileJson => $composableBuilder(
column: $table.terrainProfileJson,
builder: (column) => column,
);
}
class $$MissionsTableTableManager
@@ -2221,6 +2304,7 @@ class $$MissionsTableTableManager
Value<String> waypointsJson = const Value.absent(),
Value<bool> isDirty = const Value.absent(),
Value<String?> serverId = const Value.absent(),
Value<String?> terrainProfileJson = const Value.absent(),
}) => MissionsCompanion(
id: id,
name: name,
@@ -2230,6 +2314,7 @@ class $$MissionsTableTableManager
waypointsJson: waypointsJson,
isDirty: isDirty,
serverId: serverId,
terrainProfileJson: terrainProfileJson,
),
createCompanionCallback:
({
@@ -2241,6 +2326,7 @@ class $$MissionsTableTableManager
required String waypointsJson,
Value<bool> isDirty = const Value.absent(),
Value<String?> serverId = const Value.absent(),
Value<String?> terrainProfileJson = const Value.absent(),
}) => MissionsCompanion.insert(
id: id,
name: name,
@@ -2250,6 +2336,7 @@ class $$MissionsTableTableManager
waypointsJson: waypointsJson,
isDirty: isDirty,
serverId: serverId,
terrainProfileJson: terrainProfileJson,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
@@ -2,8 +2,10 @@ import 'dart:convert';
import 'package:drift/drift.dart';
import '../../domain/mission/terrain_math.dart';
import '../../domain/waypoint/flat_waypoint_list.dart';
import 'app_database.dart';
import 'terrain_json_codec.dart';
import 'waypoint_json_codec.dart';
/// Kopfzeilen-taugliche Zusammenfassung einer gespeicherten Mission, ohne
@@ -31,12 +33,18 @@ class LoadedMission {
required this.placeName,
required this.createdAt,
required this.waypoints,
this.terrainProfile,
});
final String name;
final String? placeName;
final DateTime createdAt;
final List<Waypoint> waypoints;
/// Gecachtes Bodenhoehenprofil (Doku 3.9), falls fuer die zuletzt
/// gespeicherte Route bereits eins abgerufen wurde - null, wenn noch
/// keins vorliegt oder es (Route inzwischen anders) verworfen werden muss.
final TerrainProfile? terrainProfile;
}
/// Persistenz-Zugriff fuer Missionen (Doku 4.15): kapselt die Drift-Tabelle
@@ -77,6 +85,9 @@ class MissionRepository {
waypoints: [
for (final entry in decoded) waypointFromJson(entry as Map<String, dynamic>),
],
terrainProfile: row.terrainProfileJson != null
? decodeTerrainProfile(row.terrainProfileJson!)
: null,
);
}
@@ -110,6 +121,16 @@ class MissionRepository {
return id;
}
/// Persistiert ein frisch abgerufenes Bodenhoehenprofil fuer eine bereits
/// gespeicherte Mission (Doku 3.9) - separat von [upsert], damit ein
/// gewoehnlicher Wegpunkt-Autosave (bei dem noch kein Profil vorliegt)
/// nicht versehentlich ein zuvor gespeichertes Profil loescht.
Future<void> saveTerrainProfile(int id, TerrainProfile profile) async {
await (_db.update(_db.missions)..where((m) => m.id.equals(id))).write(
MissionsCompanion(terrainProfileJson: Value(encodeTerrainProfile(profile))),
);
}
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())),
@@ -0,0 +1,31 @@
import 'dart:convert';
import '../../domain/mission/terrain_math.dart';
/// JSON-(De-)Serialisierung fuer [TerrainProfile] - gemeinsam genutzt von
/// der Missions-Persistenz ([MissionRepository]) und dem Mission-Sharing
/// ([MissionShareCodec]), analog waypoint_json_codec.dart.
Map<String, dynamic> terrainProfileToJson(TerrainProfile profile) => {
'routeKey': profile.routeKey,
'points': [
for (final p in profile.points)
{'distanceM': p.distanceM, 'elevationRelM': p.elevationRelM},
],
};
TerrainProfile terrainProfileFromJson(Map<String, dynamic> json) => TerrainProfile(
routeKey: json['routeKey'] as String,
points: [
for (final entry in json['points'] as List)
TerrainSamplePoint(
distanceM: (entry['distanceM'] as num).toDouble(),
elevationRelM: (entry['elevationRelM'] as num?)?.toDouble(),
),
],
);
String encodeTerrainProfile(TerrainProfile profile) =>
jsonEncode(terrainProfileToJson(profile));
TerrainProfile decodeTerrainProfile(String json) =>
terrainProfileFromJson(jsonDecode(json) as Map<String, dynamic>);
@@ -1,7 +1,9 @@
import 'dart:convert';
import '../../app_info.dart';
import '../../domain/mission/terrain_math.dart';
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../database/terrain_json_codec.dart';
import '../database/waypoint_json_codec.dart';
/// Missionsdaten fuer den Export - unabhaengig von der Drift-Zeilenklasse,
@@ -12,12 +14,18 @@ class MissionExportData {
this.placeName,
required this.createdAt,
required this.waypoints,
this.terrainProfile,
});
final String name;
final String? placeName;
final DateTime createdAt;
final List<Waypoint> waypoints;
/// Gecachtes Bodenhoehenprofil (Doku 3.9), falls fuer die Mission bereits
/// eins abgerufen wurde - wird mitexportiert, damit ein Import auf einem
/// anderen Geraet nicht erneut alle Kacheln laden muss.
final TerrainProfile? terrainProfile;
}
/// Baut die Export-Datei fuer eine Mission (Doku 3.6, analog
@@ -33,6 +41,8 @@ String buildMissionExportJson(MissionExportData mission) {
'placeName': mission.placeName,
'createdAt': mission.createdAt.toUtc().toIso8601String(),
'waypoints': [for (final w in mission.waypoints) waypointToJson(w)],
if (mission.terrainProfile != null)
'terrainProfile': terrainProfileToJson(mission.terrainProfile!),
},
};
return const JsonEncoder.withIndent(' ').convert(map);
@@ -51,6 +61,7 @@ class ParsedMissionImport {
this.placeName,
required this.waypoints,
this.versionMismatch,
this.terrainProfile,
});
final String name;
@@ -61,6 +72,11 @@ class ParsedMissionImport {
/// der Hauptversionsnummer unterscheidet - sonst null (analog der
/// "Imported from a different app version"-Warnung im HTML-Demonstrator).
final String? versionMismatch;
/// Mitexportiertes Bodenhoehenprofil (Doku 3.9), falls die Datei eins
/// enthaelt - null bei Dateien aus einer Version ohne dieses Feld oder
/// wenn die Quellmission noch keins hatte.
final TerrainProfile? terrainProfile;
}
/// Parst eine importierte Missionsdatei - wirft [FormatException] bei
@@ -100,10 +116,16 @@ ParsedMissionImport parseMissionImportJson(String jsonText) {
? appVersion
: null;
final terrainProfileRaw = missionData['terrainProfile'];
final terrainProfile = terrainProfileRaw is Map<String, dynamic>
? terrainProfileFromJson(terrainProfileRaw)
: null;
return ParsedMissionImport(
name: name,
placeName: placeName,
waypoints: waypoints,
versionMismatch: mismatch,
terrainProfile: terrainProfile,
);
}
@@ -7,6 +7,7 @@ import 'current_mission_provider.dart';
import 'database_provider.dart';
import 'follow_drone_provider.dart';
import 'map_controller_provider.dart';
import 'terrain_provider.dart';
/// Metadaten der aktuell in Bearbeitung befindlichen Mission - ergaenzt
/// currentMissionProvider (das nur die Wegpunkte haelt) um Name/Ort und die
@@ -58,6 +59,7 @@ class CurrentMissionMetaNotifier extends Notifier<MissionMeta> {
ref.read(currentMissionProvider.notifier).replaceAll(loaded.waypoints);
_suppressAutosave = false;
state = MissionMeta(id: missionId, name: loaded.name, placeName: loaded.placeName);
ref.read(terrainProvider.notifier).restore(loaded.terrainProfile);
_fitMapToWaypoints(loaded.waypoints);
}
@@ -77,6 +79,7 @@ class CurrentMissionMetaNotifier extends Notifier<MissionMeta> {
ref.read(currentMissionProvider.notifier).replaceAll(const []);
_suppressAutosave = false;
state = newState;
ref.read(terrainProvider.notifier).restore(null);
await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(null);
}
@@ -89,6 +92,7 @@ class CurrentMissionMetaNotifier extends Notifier<MissionMeta> {
ref.read(currentMissionProvider.notifier).replaceAll(loaded.waypoints);
_suppressAutosave = false;
state = MissionMeta(id: id, name: loaded.name, placeName: loaded.placeName);
ref.read(terrainProvider.notifier).restore(loaded.terrainProfile);
_fitMapToWaypoints(loaded.waypoints);
await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(id);
}
+24 -1
View File
@@ -1,8 +1,11 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/mission/terrain_math.dart';
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../../services/terrain/terrain_service.dart';
import 'database_provider.dart';
final terrainServiceProvider = Provider<TerrainService>((ref) => TerrainService());
@@ -30,7 +33,22 @@ class TerrainNotifier extends Notifier<TerrainState> {
@override
TerrainState build() => const TerrainState();
Future<void> ensureFor(List<Waypoint> waypoints) async {
/// Uebernimmt ein bereits vorliegendes Profil direkt, ohne einen
/// Netzwerkabruf auszuloesen - genutzt beim Laden einer gespeicherten
/// Mission (Doku 3.9: persistiertes Profil) bzw. zum Zuruecksetzen beim
/// Start/Wechsel einer Mission. ensureFor() prueft dessen routeKey wie
/// gewohnt, bevor es angezeigt wird, ein veraltetes Profil wird also nie
/// faelschlich fuer eine inzwischen andere Route benutzt.
void restore(TerrainProfile? profile) {
state = TerrainState(profile: profile);
}
/// [missionId] persistiert ein frisch abgerufenes Profil an der
/// gespeicherten Mission (Doku 3.9), damit ein erneutes Oeffnen derselben
/// Mission nicht wieder alle Kacheln neu laden muss - null, solange die
/// Mission noch nie gespeichert wurde (dann bleibt nur der In-Memory-Cache
/// dieser Notifier-Instanz).
Future<void> ensureFor(List<Waypoint> waypoints, {int? missionId}) async {
if (waypoints.length < 2) {
if (state.profile != null) state = const TerrainState();
return;
@@ -48,6 +66,11 @@ class TerrainNotifier extends Notifier<TerrainState> {
return;
}
state = TerrainState(profile: profile ?? state.profile, loading: false);
if (profile != null && missionId != null) {
unawaited(
ref.read(missionRepositoryProvider).saveTerrainProfile(missionId, profile),
);
}
}
}
@@ -229,6 +229,7 @@ class _MissionsDronesPanelState extends ConsumerState<MissionsDronesPanel> {
placeName: full.placeName,
createdAt: full.createdAt,
waypoints: full.waypoints,
terrainProfile: full.terrainProfile,
));
await ref.read(sharingServiceProvider).shareJsonFile(
json: json,
@@ -251,11 +252,16 @@ class _MissionsDronesPanelState extends ConsumerState<MissionsDronesPanel> {
messenger.showSnackBar(SnackBar(content: Text(e.message)));
return;
}
await ref.read(missionRepositoryProvider).upsert(
final id = await ref.read(missionRepositoryProvider).upsert(
name: parsed.name,
placeName: parsed.placeName,
waypoints: parsed.waypoints,
);
if (parsed.terrainProfile != null) {
await ref
.read(missionRepositoryProvider)
.saveTerrainProfile(id, parsed.terrainProfile!);
}
if (parsed.versionMismatch != null) {
messenger.showSnackBar(SnackBar(
content: Text(
+13 -3
View File
@@ -9,6 +9,7 @@ import '../../domain/waypoint/flat_waypoint_list.dart';
import '../../domain/wind/wind_math.dart';
import '../providers/active_drone_profile_provider.dart';
import '../providers/current_mission_provider.dart';
import '../providers/mission_meta_provider.dart';
import '../providers/terrain_provider.dart';
import '../providers/wind_provider.dart';
import 'full_value_chart.dart';
@@ -92,7 +93,10 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
// werden - sonst passiert das erst beim (dann gar nicht mehr
// stattfindenden) Antippen des Tab-Buttons.
if (_tab == PanelTab.altitude) {
ref.read(terrainProvider.notifier).ensureFor(ref.read(currentMissionProvider));
ref.read(terrainProvider.notifier).ensureFor(
ref.read(currentMissionProvider),
missionId: ref.read(currentMissionMetaProvider).id,
);
}
});
// Laedt/aktualisiert das Gelaendeprofil (Doku 3.9) nur, waehrend der
@@ -102,7 +106,10 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
// erneuter Kachel-Download noetig ist.
ref.listenManual(currentMissionProvider, (previous, next) {
if (_tab == PanelTab.altitude) {
ref.read(terrainProvider.notifier).ensureFor(next);
ref.read(terrainProvider.notifier).ensureFor(
next,
missionId: ref.read(currentMissionMetaProvider).id,
);
}
});
}
@@ -272,7 +279,10 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
if (tab == PanelTab.list) {
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActive());
} else if (tab == PanelTab.altitude) {
ref.read(terrainProvider.notifier).ensureFor(ref.read(currentMissionProvider));
ref.read(terrainProvider.notifier).ensureFor(
ref.read(currentMissionProvider),
missionId: ref.read(currentMissionMetaProvider).id,
);
}
},
child: Container(
@@ -1,6 +1,7 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:dmc_app/domain/mission/terrain_math.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';
@@ -101,4 +102,52 @@ void main() {
expect(loaded.waypoints.single.windDirFromDeg, 210);
expect(loaded.waypoints.single.windElevationM, 2);
});
group('Bodenhoehenprofil', () {
const profile = TerrainProfile(
routeKey: '52.00000,4.00000|52.01000,4.01000',
points: [
TerrainSamplePoint(distanceM: 0, elevationRelM: 0),
TerrainSamplePoint(distanceM: 30, elevationRelM: 2.5),
TerrainSamplePoint(distanceM: 60, elevationRelM: null),
],
);
test('frisch angelegte Mission hat noch kein Profil', () async {
final id = await repository.upsert(name: 'No terrain', waypoints: waypoints);
final loaded = await repository.load(id);
expect(loaded!.terrainProfile, isNull);
});
test('saveTerrainProfile persistiert das Profil, load() gibt es zurueck', () async {
final id = await repository.upsert(name: 'With terrain', waypoints: waypoints);
await repository.saveTerrainProfile(id, profile);
final loaded = await repository.load(id);
expect(loaded!.terrainProfile, isNotNull);
expect(loaded.terrainProfile!.routeKey, profile.routeKey);
expect(loaded.terrainProfile!.points, hasLength(3));
expect(loaded.terrainProfile!.points[1].elevationRelM, 2.5);
expect(loaded.terrainProfile!.points[2].elevationRelM, isNull);
});
test('ein gewoehnlicher upsert (Wegpunkt-Autosave) loescht ein bereits '
'gespeichertes Profil nicht', () async {
final id = await repository.upsert(name: 'Autosave', waypoints: waypoints);
await repository.saveTerrainProfile(id, profile);
await repository.upsert(
id: id,
name: 'Autosave',
waypoints: [waypoints.first],
);
final loaded = await repository.load(id);
expect(loaded!.terrainProfile, isNotNull);
expect(loaded.terrainProfile!.routeKey, profile.routeKey);
});
});
}
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:dmc_app/domain/mission/terrain_math.dart';
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
import 'package:dmc_app/services/sharing/mission_share_codec.dart';
@@ -105,6 +106,47 @@ void main() {
});
});
group('Bodenhoehenprofil im Export', () {
const profile = TerrainProfile(
routeKey: '48.13510,11.58200|48.13600,11.58300',
points: [
TerrainSamplePoint(distanceM: 0, elevationRelM: 0),
TerrainSamplePoint(distanceM: 50, elevationRelM: -3.2),
TerrainSamplePoint(distanceM: 100, elevationRelM: null),
],
);
test('wird mitexportiert und beim Import wiederhergestellt', () {
final json = buildMissionExportJson(MissionExportData(
name: 'Munich Loop',
placeName: 'Munich',
createdAt: DateTime.utc(2026, 1, 1),
waypoints: waypoints,
terrainProfile: profile,
));
final parsed = parseMissionImportJson(json);
expect(parsed.terrainProfile, isNotNull);
expect(parsed.terrainProfile!.routeKey, profile.routeKey);
expect(parsed.terrainProfile!.points, hasLength(3));
expect(parsed.terrainProfile!.points[1].elevationRelM, -3.2);
expect(parsed.terrainProfile!.points[2].elevationRelM, isNull);
});
test('fehlt in der Datei, bleibt terrainProfile beim Import null', () {
final json = buildMissionExportJson(MissionExportData(
name: 'No terrain',
createdAt: DateTime.utc(2026, 1, 1),
waypoints: waypoints,
));
final parsed = parseMissionImportJson(json);
expect(parsed.terrainProfile, isNull);
});
});
group('missionExportFilename', () {
test('ersetzt Sonderzeichen durch Unterstriche', () {
expect(missionExportFilename('Sankt Pölten / Nord'),