Files
dmc/app/lib/services/sharing/mission_share_codec.dart
Constantin LeueandClaude Sonnet 5 35351140ae 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>
2026-08-04 08:10:41 +02:00

132 lines
4.5 KiB
Dart

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,
/// damit dieser Codec ohne Datenbankzugriff testbar bleibt.
class MissionExportData {
const MissionExportData({
required this.name,
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
/// buildMissionExportObject()/shareMission() im HTML-Demonstrator) - ein
/// mit `appVersion`/`exportedAt` umschlossenes JSON-Dokument, damit ein
/// Import spaeter erkennen kann, aus welcher App-Version die Datei stammt.
String buildMissionExportJson(MissionExportData mission) {
final map = {
'appVersion': kAppVersion,
'exportedAt': DateTime.now().toUtc().toIso8601String(),
'mission': {
'name': mission.name,
'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);
}
/// Vorschlagsname fuer die geteilte Datei (Doku 3.6, analog
/// `'mission-' + safeName + '.json'` im HTML-Demonstrator).
String missionExportFilename(String label) {
final safeName = label.replaceAll(RegExp(r'[^a-zA-Z0-9]'), '_');
return 'mission-$safeName.json';
}
class ParsedMissionImport {
const ParsedMissionImport({
required this.name,
this.placeName,
required this.waypoints,
this.versionMismatch,
this.terrainProfile,
});
final String name;
final String? placeName;
final List<Waypoint> waypoints;
/// Enthaelt die appVersion der Datei, falls sie sich von [kAppVersion] in
/// 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
/// ungueltigem JSON oder fehlenden Wegpunkten (Doku 3.6, analog dem
/// `change`-Handler von importMissionInput im HTML-Demonstrator).
ParsedMissionImport parseMissionImportJson(String jsonText) {
final Object? decoded;
try {
decoded = jsonDecode(jsonText);
} on FormatException {
throw const FormatException('Invalid mission file');
}
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Invalid mission file');
}
final missionData = decoded['mission'] ?? decoded;
if (missionData is! Map<String, dynamic>) {
throw const FormatException('Invalid mission file');
}
final waypointsRaw = missionData['waypoints'];
if (waypointsRaw is! List || waypointsRaw.isEmpty) {
throw const FormatException('File has no waypoints');
}
final waypoints = [
for (final w in waypointsRaw) waypointFromJson(w as Map<String, dynamic>),
];
final name = (missionData['name'] as String?) ??
(missionData['placeName'] as String?) ??
'Imported mission';
final placeName =
(missionData['placeName'] as String?) ?? (missionData['name'] as String?);
final appVersion = decoded['appVersion'] as String?;
final mismatch = appVersion != null &&
appVersion.split('.').first != kAppVersion.split('.').first
? 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,
);
}