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>);