diff --git a/app/lib/services/database/app_database.dart b/app/lib/services/database/app_database.dart index aa3e534..aaf5e30 100644 --- a/app/lib/services/database/app_database.dart +++ b/app/lib/services/database/app_database.dart @@ -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); + } }, ); diff --git a/app/lib/services/database/app_database.g.dart b/app/lib/services/database/app_database.g.dart index b2a0cc9..17693c8 100644 --- a/app/lib/services/database/app_database.g.dart +++ b/app/lib/services/database/app_database.g.dart @@ -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 terrainProfileJson = + GeneratedColumn( + 'terrain_profile_json', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List 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 { 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 { required this.waypointsJson, required this.isDirty, this.serverId, + this.terrainProfileJson, }); @override Map toColumns(bool nullToAbsent) { @@ -261,6 +295,9 @@ class Mission extends DataClass implements Insertable { if (!nullToAbsent || serverId != null) { map['server_id'] = Variable(serverId); } + if (!nullToAbsent || terrainProfileJson != null) { + map['terrain_profile_json'] = Variable(terrainProfileJson); + } return map; } @@ -278,6 +315,9 @@ class Mission extends DataClass implements Insertable { 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 { waypointsJson: serializer.fromJson(json['waypointsJson']), isDirty: serializer.fromJson(json['isDirty']), serverId: serializer.fromJson(json['serverId']), + terrainProfileJson: serializer.fromJson( + json['terrainProfileJson'], + ), ); } @override @@ -309,6 +352,7 @@ class Mission extends DataClass implements Insertable { 'waypointsJson': serializer.toJson(waypointsJson), 'isDirty': serializer.toJson(isDirty), 'serverId': serializer.toJson(serverId), + 'terrainProfileJson': serializer.toJson(terrainProfileJson), }; } @@ -321,6 +365,7 @@ class Mission extends DataClass implements Insertable { String? waypointsJson, bool? isDirty, Value serverId = const Value.absent(), + Value terrainProfileJson = const Value.absent(), }) => Mission( id: id ?? this.id, name: name ?? this.name, @@ -330,6 +375,9 @@ class Mission extends DataClass implements Insertable { 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 { : 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 { ..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 { waypointsJson, isDirty, serverId, + terrainProfileJson, ); @override bool operator ==(Object other) => @@ -383,7 +436,8 @@ class Mission extends DataClass implements Insertable { 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 { @@ -395,6 +449,7 @@ class MissionsCompanion extends UpdateCompanion { final Value waypointsJson; final Value isDirty; final Value serverId; + final Value terrainProfileJson; const MissionsCompanion({ this.id = const Value.absent(), this.name = const Value.absent(), @@ -404,6 +459,7 @@ class MissionsCompanion extends UpdateCompanion { 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 { 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 custom({ @@ -425,6 +482,7 @@ class MissionsCompanion extends UpdateCompanion { Expression? waypointsJson, Expression? isDirty, Expression? serverId, + Expression? terrainProfileJson, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -435,6 +493,8 @@ class MissionsCompanion extends UpdateCompanion { 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 { Value? waypointsJson, Value? isDirty, Value? serverId, + Value? terrainProfileJson, }) { return MissionsCompanion( id: id ?? this.id, @@ -457,6 +518,7 @@ class MissionsCompanion extends UpdateCompanion { waypointsJson: waypointsJson ?? this.waypointsJson, isDirty: isDirty ?? this.isDirty, serverId: serverId ?? this.serverId, + terrainProfileJson: terrainProfileJson ?? this.terrainProfileJson, ); } @@ -487,6 +549,9 @@ class MissionsCompanion extends UpdateCompanion { if (serverId.present) { map['server_id'] = Variable(serverId.value); } + if (terrainProfileJson.present) { + map['terrain_profile_json'] = Variable(terrainProfileJson.value); + } return map; } @@ -500,7 +565,8 @@ class MissionsCompanion extends UpdateCompanion { ..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 isDirty, Value serverId, + Value terrainProfileJson, }); typedef $$MissionsTableUpdateCompanionBuilder = MissionsCompanion Function({ @@ -2047,6 +2114,7 @@ typedef $$MissionsTableUpdateCompanionBuilder = Value waypointsJson, Value isDirty, Value serverId, + Value terrainProfileJson, }); class $$MissionsTableFilterComposer @@ -2097,6 +2165,11 @@ class $$MissionsTableFilterComposer column: $table.serverId, builder: (column) => ColumnFilters(column), ); + + ColumnFilters 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 get terrainProfileJson => $composableBuilder( + column: $table.terrainProfileJson, + builder: (column) => ColumnOrderings(column), + ); } class $$MissionsTableAnnotationComposer @@ -2183,6 +2261,11 @@ class $$MissionsTableAnnotationComposer GeneratedColumn get serverId => $composableBuilder(column: $table.serverId, builder: (column) => column); + + GeneratedColumn get terrainProfileJson => $composableBuilder( + column: $table.terrainProfileJson, + builder: (column) => column, + ); } class $$MissionsTableTableManager @@ -2221,6 +2304,7 @@ class $$MissionsTableTableManager Value waypointsJson = const Value.absent(), Value isDirty = const Value.absent(), Value serverId = const Value.absent(), + Value 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 isDirty = const Value.absent(), Value serverId = const Value.absent(), + Value 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))) diff --git a/app/lib/services/database/mission_repository.dart b/app/lib/services/database/mission_repository.dart index dab81cc..2407417 100644 --- a/app/lib/services/database/mission_repository.dart +++ b/app/lib/services/database/mission_repository.dart @@ -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 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), ], + 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 saveTerrainProfile(int id, TerrainProfile profile) async { + await (_db.update(_db.missions)..where((m) => m.id.equals(id))).write( + MissionsCompanion(terrainProfileJson: Value(encodeTerrainProfile(profile))), + ); + } + Future 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())), diff --git a/app/lib/services/database/terrain_json_codec.dart b/app/lib/services/database/terrain_json_codec.dart new file mode 100644 index 0000000..93e7af2 --- /dev/null +++ b/app/lib/services/database/terrain_json_codec.dart @@ -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 terrainProfileToJson(TerrainProfile profile) => { + 'routeKey': profile.routeKey, + 'points': [ + for (final p in profile.points) + {'distanceM': p.distanceM, 'elevationRelM': p.elevationRelM}, + ], + }; + +TerrainProfile terrainProfileFromJson(Map 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); diff --git a/app/lib/services/sharing/mission_share_codec.dart b/app/lib/services/sharing/mission_share_codec.dart index d6b82cc..515d5c1 100644 --- a/app/lib/services/sharing/mission_share_codec.dart +++ b/app/lib/services/sharing/mission_share_codec.dart @@ -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 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 + ? terrainProfileFromJson(terrainProfileRaw) + : null; + return ParsedMissionImport( name: name, placeName: placeName, waypoints: waypoints, versionMismatch: mismatch, + terrainProfile: terrainProfile, ); } diff --git a/app/lib/ui/providers/mission_meta_provider.dart b/app/lib/ui/providers/mission_meta_provider.dart index 157f44d..df0ea5c 100644 --- a/app/lib/ui/providers/mission_meta_provider.dart +++ b/app/lib/ui/providers/mission_meta_provider.dart @@ -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 { 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 { 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 { 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); } diff --git a/app/lib/ui/providers/terrain_provider.dart b/app/lib/ui/providers/terrain_provider.dart index cb032b5..499af65 100644 --- a/app/lib/ui/providers/terrain_provider.dart +++ b/app/lib/ui/providers/terrain_provider.dart @@ -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((ref) => TerrainService()); @@ -30,7 +33,22 @@ class TerrainNotifier extends Notifier { @override TerrainState build() => const TerrainState(); - Future ensureFor(List 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 ensureFor(List waypoints, {int? missionId}) async { if (waypoints.length < 2) { if (state.profile != null) state = const TerrainState(); return; @@ -48,6 +66,11 @@ class TerrainNotifier extends Notifier { return; } state = TerrainState(profile: profile ?? state.profile, loading: false); + if (profile != null && missionId != null) { + unawaited( + ref.read(missionRepositoryProvider).saveTerrainProfile(missionId, profile), + ); + } } } diff --git a/app/lib/ui/widgets/missions_drones_panel.dart b/app/lib/ui/widgets/missions_drones_panel.dart index b4171f9..8d157d5 100644 --- a/app/lib/ui/widgets/missions_drones_panel.dart +++ b/app/lib/ui/widgets/missions_drones_panel.dart @@ -229,6 +229,7 @@ class _MissionsDronesPanelState extends ConsumerState { 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 { 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( diff --git a/app/lib/ui/widgets/waypoint_list_panel.dart b/app/lib/ui/widgets/waypoint_list_panel.dart index a4ca62f..316c6d6 100644 --- a/app/lib/ui/widgets/waypoint_list_panel.dart +++ b/app/lib/ui/widgets/waypoint_list_panel.dart @@ -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 { // 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 { // 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 { 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( diff --git a/app/test/services/mission_repository_test.dart b/app/test/services/mission_repository_test.dart index cf0620f..059aa76 100644 --- a/app/test/services/mission_repository_test.dart +++ b/app/test/services/mission_repository_test.dart @@ -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); + }); + }); } diff --git a/app/test/services/sharing/mission_share_codec_test.dart b/app/test/services/sharing/mission_share_codec_test.dart index ceb5b00..0e9b8e5 100644 --- a/app/test/services/sharing/mission_share_codec_test.dart +++ b/app/test/services/sharing/mission_share_codec_test.dart @@ -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'),