Auto-fetch wind, drop manual fetch/validate buttons, fix it in fly mode
Redesigns the waypoint list panel to match the drone-status/missions panels: no title bar, close button floats over the tab row, tabs get more vertical padding. Removes the "Fetch wind" and "Validate" header buttons along with the now-fully-orphaned WindValidatePanel screen and its WindService support code (fetchValidation, WindValidationResult and friends) - nothing else referenced them. Wind is now fetched automatically instead: HeaderWindNotifier gains ensurePerWaypointWind(), called when the waypoint list panel opens and when the header pill's per-waypoint toggle switches on. A lat/lon signature of the waypoint list tracks whether the fetched data is still current, so a ref.listen(currentMissionProvider) in the same notifier refetches only when the list itself changes (position added/ removed/moved) - not on every altitude/speed edit. Waypoints that already carry wind data (e.g. loaded from a saved mission) short- circuit the check instead of re-fetching pointlessly. Also wires per-waypoint wind markers into the fly-mode map, which never had them (plan_screen.dart built windMarkers; fly_screen.dart just never did) - the reported "doesn't work in fly mode" bug. windMarkerWidth() moved from a plan_screen.dart-private method to wind_marker_pill.dart so both screens can share it. Test containers in widget_test.dart now override windServiceProvider with a MockClient so the new auto-fetch trigger can't reach the real network during tests.
This commit is contained in:
@@ -142,79 +142,4 @@ void main() {
|
||||
expect(called, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('WindService.fetchValidation', () {
|
||||
test('markiert unvollstaendige/unter Boden liegende Druckflaechen als excluded',
|
||||
() async {
|
||||
final client = MockClient((request) async {
|
||||
if (_isPressureRequest(request)) {
|
||||
return _json({
|
||||
'elevation': 50.0,
|
||||
'hourly': {
|
||||
'time': ['2024-06-01T12:00'],
|
||||
'wind_speed_1000hPa': [4.0],
|
||||
'wind_direction_1000hPa': [300.0],
|
||||
'geopotential_height_1000hPa': [5.0], // unter Bodenhoehe 50
|
||||
'wind_speed_975hPa': [12.0],
|
||||
'wind_direction_975hPa': [300.0],
|
||||
'geopotential_height_975hPa': [800.0],
|
||||
},
|
||||
});
|
||||
}
|
||||
return _json({
|
||||
'elevation': 50.0,
|
||||
'hourly': {
|
||||
'time': ['2024-06-01T12:00'],
|
||||
'wind_speed_10m': [1.0],
|
||||
'wind_direction_10m': [100.0],
|
||||
'wind_speed_80m': [9.0],
|
||||
'wind_direction_80m': [250.0],
|
||||
'wind_speed_120m': [10.0],
|
||||
'wind_direction_120m': [260.0],
|
||||
'wind_speed_180m': [11.0],
|
||||
'wind_direction_180m': [270.0],
|
||||
'wind_gusts_10m': [12.5],
|
||||
},
|
||||
});
|
||||
});
|
||||
final service = WindService(client: client);
|
||||
|
||||
final result = await service.fetchValidation(52.0, 4.0);
|
||||
|
||||
expect(result.elevationM, closeTo(50.0, 0.001));
|
||||
expect(result.fixedRequestFailed, isFalse);
|
||||
expect(result.pressureRequestFailed, isFalse);
|
||||
expect(result.gustMs, closeTo(12.5, 0.001));
|
||||
|
||||
final level1000 =
|
||||
result.pressureReadings.firstWhere((r) => r.pressureHpa == 1000);
|
||||
expect(level1000.excluded, isTrue,
|
||||
reason: 'liegt unter der Bodenhoehe');
|
||||
|
||||
final level975 =
|
||||
result.pressureReadings.firstWhere((r) => r.pressureHpa == 975);
|
||||
expect(level975.excluded, isFalse);
|
||||
|
||||
final level950 =
|
||||
result.pressureReadings.firstWhere((r) => r.pressureHpa == 950);
|
||||
expect(level950.excluded, isTrue,
|
||||
reason: 'keine Daten fuer diese Druckflaeche geliefert');
|
||||
|
||||
expect(result.interpolatedAt120, isNotNull);
|
||||
});
|
||||
|
||||
test('meldet fehlgeschlagenen Request statt zu crashen', () async {
|
||||
final client = MockClient((request) async {
|
||||
if (_isPressureRequest(request)) return http.Response('', 500);
|
||||
return http.Response('', 500);
|
||||
});
|
||||
final service = WindService(client: client);
|
||||
|
||||
final result = await service.fetchValidation(52.0, 4.0);
|
||||
|
||||
expect(result.fixedRequestFailed, isTrue);
|
||||
expect(result.pressureRequestFailed, isTrue);
|
||||
expect(result.interpolatedAt120, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
|
||||
import 'package:dmc_app/services/wind/wind_service.dart';
|
||||
import 'package:dmc_app/ui/providers/current_mission_provider.dart';
|
||||
import 'package:dmc_app/ui/providers/wind_provider.dart';
|
||||
|
||||
/// Zaehlt fetchWindForWaypoints()-Aufrufe statt echte HTTP-Requests zu
|
||||
/// stellen - schreibt synthetische Winddaten zurueck, damit
|
||||
/// ensurePerWaypointWind()s "bereits vorhanden"-Kurzschluss (siehe dortige
|
||||
/// Doku) bei einem zweiten Aufruf greifen kann.
|
||||
class _CountingWindService extends WindService {
|
||||
int fetchCalls = 0;
|
||||
|
||||
@override
|
||||
Future<List<Waypoint>> fetchWindForWaypoints(List<Waypoint> waypoints) async {
|
||||
fetchCalls++;
|
||||
return [
|
||||
for (final w in waypoints) w.copyWith(windSpeedMs: 5, windDirFromDeg: 200),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Waypoint _wp(double lat, double lon, {double? windSpeedMs}) => Waypoint(
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
altitudeM: 60,
|
||||
speedMs: 15,
|
||||
catchRadiusM: 60,
|
||||
windSpeedMs: windSpeedMs,
|
||||
windDirFromDeg: windSpeedMs != null ? 180 : null,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('ensurePerWaypointWind() fetches once for a list without wind data', () async {
|
||||
final service = _CountingWindService();
|
||||
final container = ProviderContainer(overrides: [
|
||||
windServiceProvider.overrideWithValue(service),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0));
|
||||
|
||||
await container.read(headerWindProvider.notifier).ensurePerWaypointWind();
|
||||
|
||||
expect(service.fetchCalls, 1);
|
||||
expect(container.read(currentMissionProvider).single.windSpeedMs, 5);
|
||||
});
|
||||
|
||||
test('ensurePerWaypointWind() does not fetch when every waypoint already '
|
||||
'has wind data (e.g. loaded from a saved mission)', () async {
|
||||
final service = _CountingWindService();
|
||||
final container = ProviderContainer(overrides: [
|
||||
windServiceProvider.overrideWithValue(service),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0, windSpeedMs: 3));
|
||||
|
||||
await container.read(headerWindProvider.notifier).ensurePerWaypointWind();
|
||||
|
||||
expect(service.fetchCalls, 0);
|
||||
});
|
||||
|
||||
test('a second ensurePerWaypointWind() call for the unchanged list does '
|
||||
'not fetch again', () async {
|
||||
final service = _CountingWindService();
|
||||
final container = ProviderContainer(overrides: [
|
||||
windServiceProvider.overrideWithValue(service),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0));
|
||||
|
||||
final notifier = container.read(headerWindProvider.notifier);
|
||||
await notifier.ensurePerWaypointWind();
|
||||
await notifier.ensurePerWaypointWind();
|
||||
|
||||
expect(service.fetchCalls, 1);
|
||||
});
|
||||
|
||||
test('toggleShowPerWaypoint() fetches when turning on, not when turning off', () async {
|
||||
final service = _CountingWindService();
|
||||
final container = ProviderContainer(overrides: [
|
||||
windServiceProvider.overrideWithValue(service),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0));
|
||||
|
||||
final notifier = container.read(headerWindProvider.notifier);
|
||||
notifier.toggleShowPerWaypoint(); // an
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(container.read(headerWindProvider).showPerWaypoint, isTrue);
|
||||
expect(service.fetchCalls, 1);
|
||||
|
||||
notifier.toggleShowPerWaypoint(); // aus
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(container.read(headerWindProvider).showPerWaypoint, isFalse);
|
||||
expect(service.fetchCalls, 1, reason: 'Ausschalten loest keinen Abruf aus');
|
||||
});
|
||||
|
||||
test('after activation, changing the waypoint list (e.g. adding a point) '
|
||||
'triggers an automatic refetch', () async {
|
||||
final service = _CountingWindService();
|
||||
final container = ProviderContainer(overrides: [
|
||||
windServiceProvider.overrideWithValue(service),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
final missionNotifier = container.read(currentMissionProvider.notifier);
|
||||
missionNotifier.addWaypoint(_wp(0, 0));
|
||||
|
||||
await container.read(headerWindProvider.notifier).ensurePerWaypointWind();
|
||||
expect(service.fetchCalls, 1);
|
||||
|
||||
missionNotifier.addWaypoint(_wp(0, 0.01));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(service.fetchCalls, 2,
|
||||
reason: 'die veraenderte Wegpunktliste loest automatisch neu aus');
|
||||
});
|
||||
|
||||
test('after activation, editing only altitude (position unchanged) does '
|
||||
'NOT trigger a refetch', () async {
|
||||
final service = _CountingWindService();
|
||||
final container = ProviderContainer(overrides: [
|
||||
windServiceProvider.overrideWithValue(service),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
final missionNotifier = container.read(currentMissionProvider.notifier);
|
||||
missionNotifier.addWaypoint(_wp(0, 0));
|
||||
|
||||
await container.read(headerWindProvider.notifier).ensurePerWaypointWind();
|
||||
expect(service.fetchCalls, 1);
|
||||
|
||||
missionNotifier.setAltitude(0, 150);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(service.fetchCalls, 1,
|
||||
reason: 'nur die Position ist Teil der Aenderungs-Signatur, nicht die Hoehe');
|
||||
});
|
||||
|
||||
test('before activation (menu never opened, pill never toggled), list '
|
||||
'changes trigger no fetch at all', () async {
|
||||
final service = _CountingWindService();
|
||||
final container = ProviderContainer(overrides: [
|
||||
windServiceProvider.overrideWithValue(service),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
// Beobachtet headerWindProvider, ohne ensurePerWaypointWind() oder
|
||||
// toggleShowPerWaypoint() aufzurufen - build() registriert den Listener
|
||||
// zwar, er soll aber inaktiv bleiben.
|
||||
container.read(headerWindProvider);
|
||||
|
||||
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(service.fetchCalls, 0);
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import 'package:dmc_app/services/geocoding/nominatim_service.dart';
|
||||
import 'package:dmc_app/services/sharing/drone_share_codec.dart';
|
||||
import 'package:dmc_app/services/sharing/mission_share_codec.dart';
|
||||
import 'package:dmc_app/services/sharing/sharing_service.dart';
|
||||
import 'package:dmc_app/services/wind/wind_service.dart';
|
||||
import 'package:dmc_app/ui/app_shell.dart';
|
||||
import 'package:dmc_app/ui/screens/fly/fly_screen.dart';
|
||||
import 'package:dmc_app/ui/providers/active_drone_profile_provider.dart';
|
||||
@@ -28,6 +29,7 @@ import 'package:dmc_app/ui/providers/mission_meta_provider.dart';
|
||||
import 'package:dmc_app/ui/providers/sharing_provider.dart';
|
||||
import 'package:dmc_app/ui/providers/system_message_log_provider.dart';
|
||||
import 'package:dmc_app/ui/providers/telemetry_provider.dart';
|
||||
import 'package:dmc_app/ui/providers/wind_provider.dart';
|
||||
import 'package:dmc_app/transport/mock/mock_flight_controller_link.dart';
|
||||
import 'package:dmc_app/ui/widgets/drone_follow_icon.dart';
|
||||
import 'package:dmc_app/ui/widgets/drone_status_messages_panel.dart';
|
||||
@@ -89,6 +91,16 @@ ProviderContainer _testContainer() => ProviderContainer(
|
||||
ref.onDispose(link.disconnect);
|
||||
return link;
|
||||
}),
|
||||
// Ohne diesen Override wuerde headerWindProvider.ensurePerWaypointWind()
|
||||
// (seit dem automatischen Windabruf beim Oeffnen der Wegpunktliste/
|
||||
// Aktivieren der Kopfleisten-Pille) einen echten Netzwerkrequest
|
||||
// ausloesen, sobald ein Test Wegpunkte ohne bereits gesetzte
|
||||
// Winddaten anlegt - MockClient antwortet stattdessen sofort mit
|
||||
// einem Fehlschlag, den WindService._fetchJson ohnehin schon
|
||||
// abfaengt (liefert dann einfach kein Windergebnis).
|
||||
windServiceProvider.overrideWithValue(
|
||||
WindService(client: MockClient((request) async => http.Response('', 500))),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -108,6 +120,9 @@ Widget _wrap(Widget child) => ProviderScope(
|
||||
ref.onDispose(link.disconnect);
|
||||
return link;
|
||||
}),
|
||||
windServiceProvider.overrideWithValue(
|
||||
WindService(client: MockClient((request) async => http.Response('', 500))),
|
||||
),
|
||||
],
|
||||
child: BlocProvider(
|
||||
create: (_) => AppModeCubit(),
|
||||
|
||||
Reference in New Issue
Block a user