- BottomStatsBar: persistent "Details: N WP · dist m · duration" pill
(HTML prototype's #bottomBar/#statsRow), tap opens the full waypoint
list. Mission/drone name and the Send button are intentionally not part
of this - mission library and upload aren't wired up yet.
- A warn banner ("Route exceeds drone capabilities at highlighted point")
appears above it whenever buildRouteGeometry finds a bad vertex or leg -
a direct, low-effort payoff from the fillet geometry work.
- WaypointListPanel: full-screen overlay listing every waypoint
(altitude/speed/execute/catch radius), matching the prototype's List
tab. Only one row editable at a time; editing swaps plain text for
draggable WaypointChip values and an execute dropdown. Row tap (outside
edit mode) centers the map on that waypoint and closes the panel; delete
removes it and adjusts the reticle's target index the same way the halo
menu's remove already does.
- WaypointChip: vertical-drag value chip (HTML prototype's .value-chip),
fixed step of 10 per 14px matching chipFieldConfig - simpler than the
original's dual vertical/horizontal axis-lock gesture, which felt like
more fidelity than the interaction warrants here.
- Provider gained setCatchRadius and setAction (direct assignment, as
opposed to toggleAction's toggle-to-none semantics used by the halo
menu) to support the list's editable fields.
- Altitude/Speed full-chart tabs from the prototype (drag-to-adjust
altitude/speed profile charts) are a separate, larger feature and not
part of this step.
Centering on a waypoint from the list calls MapController.move(), which -
like the reticle's auto-snap - doesn't emit MoveEnd, so snap detection is
invoked manually afterward to match the prototype's resulting behavior.
Added 4 widget tests (open empty list, row-tap centers and closes, delete
removes and updates the empty state, chip drag updates altitude) - one
required a pump(duration) after the drag to flush the chip's 120ms flash
Timer, which was otherwise still pending at test teardown. All 15 tests
and flutter analyze pass. Manually verified on the Pixel_10a emulator:
opened the list with two waypoints, edited a row's altitude via chip drag
(confirmed value change, accounting for device-pixel-ratio scaling vs the
widget-test logical-pixel case), and confirmed row-tap re-centers/closes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
257 lines
8.1 KiB
Dart
257 lines
8.1 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:dmc_app/app_mode/app_mode_cubit.dart';
|
|
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
|
|
import 'package:dmc_app/ui/app_shell.dart';
|
|
import 'package:dmc_app/ui/providers/current_mission_provider.dart';
|
|
import 'package:dmc_app/ui/widgets/halo_menu.dart';
|
|
import 'package:dmc_app/ui/widgets/reticle_button.dart';
|
|
import 'package:dmc_app/ui/widgets/waypoint_chip.dart';
|
|
import 'package:dmc_app/ui/widgets/waypoint_list_panel.dart';
|
|
|
|
Widget _wrap(Widget child) => ProviderScope(
|
|
child: BlocProvider(
|
|
create: (_) => AppModeCubit(),
|
|
child: MaterialApp(home: child),
|
|
),
|
|
);
|
|
|
|
Future<ProviderContainer> _pumpWithContainer(WidgetTester tester) async {
|
|
final container = ProviderContainer();
|
|
await tester.pumpWidget(
|
|
UncontrolledProviderScope(
|
|
container: container,
|
|
child: BlocProvider(
|
|
create: (_) => AppModeCubit(),
|
|
child: const MaterialApp(home: AppShell()),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
return container;
|
|
}
|
|
|
|
void main() {
|
|
testWidgets('AppShell zeigt die Karte und den Plan/Fly-Umschalter im Startzustand',
|
|
(tester) async {
|
|
await tester.pumpWidget(_wrap(const AppShell()));
|
|
// Nur ein Frame pumpen statt pumpAndSettle: TileLayer laedt echte
|
|
// Netzwerk-Kacheln, die in der Testumgebung nie ankommen.
|
|
await tester.pump();
|
|
|
|
expect(find.byType(FlutterMap), findsOneWidget);
|
|
expect(find.text('Plan'), findsOneWidget);
|
|
expect(find.text('Fly'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets(
|
|
'Fly-Button ohne erfuellten Ready-to-Fly-Gate zeigt Hinweis statt Absturz',
|
|
(tester) async {
|
|
await tester.pumpWidget(_wrap(const AppShell()));
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.text('Fly'));
|
|
await tester.pump();
|
|
|
|
expect(
|
|
find.text('Mission muss zuerst hochgeladen und verifiziert werden.'),
|
|
findsOneWidget,
|
|
);
|
|
// Weiterhin im Plan-Modus, da das Gate nicht erfuellt war.
|
|
expect(find.byType(FlutterMap), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Reticle-Button fuegt einen Wegpunkt am Kartenzentrum hinzu',
|
|
(tester) async {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
UncontrolledProviderScope(
|
|
container: container,
|
|
child: BlocProvider(
|
|
create: (_) => AppModeCubit(),
|
|
child: const MaterialApp(home: AppShell()),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(container.read(currentMissionProvider), isEmpty);
|
|
|
|
await tester.tap(find.byType(ReticleButton));
|
|
await tester.pump();
|
|
|
|
final waypoints = container.read(currentMissionProvider);
|
|
expect(waypoints, hasLength(1));
|
|
expect(waypoints.single.lat, closeTo(52.3676, 0.0001));
|
|
expect(waypoints.single.lon, closeTo(4.9041, 0.0001));
|
|
});
|
|
|
|
testWidgets(
|
|
'Zweiter Tap auf den frisch gesetzten Wegpunkt oeffnet das Halo-Menue',
|
|
(tester) async {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
UncontrolledProviderScope(
|
|
container: container,
|
|
child: BlocProvider(
|
|
create: (_) => AppModeCubit(),
|
|
child: const MaterialApp(home: AppShell()),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
// 1. Tap: idle -> snapped (legt Wegpunkt an).
|
|
await tester.tap(find.byType(ReticleButton));
|
|
await tester.pump();
|
|
expect(find.byType(HaloMenu), findsNothing);
|
|
|
|
// 2. Tap: snapped -> editing.
|
|
await tester.tap(find.byType(ReticleButton));
|
|
await tester.pump();
|
|
expect(find.byType(HaloMenu), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Halo-Remove entfernt den Wegpunkt und schliesst das Menue',
|
|
(tester) async {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
UncontrolledProviderScope(
|
|
container: container,
|
|
child: BlocProvider(
|
|
create: (_) => AppModeCubit(),
|
|
child: const MaterialApp(home: AppShell()),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.byType(ReticleButton)); // idle -> snapped
|
|
await tester.pump();
|
|
await tester.tap(find.byType(ReticleButton)); // snapped -> editing
|
|
await tester.pump();
|
|
|
|
// Die Wedge-Bounding-Box-Mitte liegt ausserhalb der Keilform (Donut-
|
|
// Segment) - stattdessen exakt auf das (nicht hit-testbare) Icon tippen,
|
|
// das per Konstruktion innerhalb des Keils sitzt.
|
|
await tester.tapAt(tester.getCenter(find.byIcon(Icons.remove)));
|
|
await tester.pump();
|
|
|
|
expect(container.read(currentMissionProvider), isEmpty);
|
|
expect(find.byType(HaloMenu), findsNothing);
|
|
});
|
|
|
|
testWidgets('Halo-Loiter setzt die Execute-Aktion und schliesst das Menue',
|
|
(tester) async {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
UncontrolledProviderScope(
|
|
container: container,
|
|
child: BlocProvider(
|
|
create: (_) => AppModeCubit(),
|
|
child: const MaterialApp(home: AppShell()),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.byType(ReticleButton)); // idle -> snapped
|
|
await tester.pump();
|
|
await tester.tap(find.byType(ReticleButton)); // snapped -> editing
|
|
await tester.pump();
|
|
|
|
await tester.tapAt(tester.getCenter(find.byIcon(Icons.loop)));
|
|
await tester.pump();
|
|
|
|
final waypoints = container.read(currentMissionProvider);
|
|
expect(waypoints.single.action, WaypointAction.loiter);
|
|
expect(find.byType(HaloMenu), findsNothing);
|
|
});
|
|
|
|
testWidgets('Bottom-Stats-Bar oeffnet die leere Wegpunktliste',
|
|
(tester) async {
|
|
await _pumpWithContainer(tester);
|
|
|
|
await tester.tap(find.text('Details:'));
|
|
await tester.pump();
|
|
|
|
expect(find.byType(WaypointListPanel), findsOneWidget);
|
|
expect(find.text('No waypoints yet'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Zeile antippen zentriert die Karte und schliesst die Liste',
|
|
(tester) async {
|
|
await _pumpWithContainer(tester);
|
|
|
|
await tester.tap(find.byType(ReticleButton));
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.text('Details:'));
|
|
await tester.pump();
|
|
expect(find.byType(WaypointListPanel), findsOneWidget);
|
|
|
|
await tester.tap(find.text('60 m').first);
|
|
await tester.pump();
|
|
|
|
expect(find.byType(WaypointListPanel), findsNothing);
|
|
});
|
|
|
|
testWidgets('Papierkorb-Icon in der Liste entfernt den Wegpunkt',
|
|
(tester) async {
|
|
final container = await _pumpWithContainer(tester);
|
|
|
|
await tester.tap(find.byType(ReticleButton));
|
|
await tester.pump();
|
|
await tester.tap(find.text('Details:'));
|
|
await tester.pump();
|
|
|
|
expect(container.read(currentMissionProvider), hasLength(1));
|
|
|
|
await tester.tap(find.byIcon(Icons.delete_outline));
|
|
await tester.pump();
|
|
|
|
expect(container.read(currentMissionProvider), isEmpty);
|
|
expect(find.text('No waypoints yet'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets(
|
|
'Wertechip in der Liste aendert die Hoehe des Wegpunkts per Drag',
|
|
(tester) async {
|
|
final container = await _pumpWithContainer(tester);
|
|
|
|
await tester.tap(find.byType(ReticleButton));
|
|
await tester.pump();
|
|
await tester.tap(find.text('Details:'));
|
|
await tester.pump();
|
|
|
|
// Icons.edit erscheint auch auf dem ReticleButton im Modus "snapped" -
|
|
// gezielt das Edit-Icon der Listenzeile ansprechen.
|
|
await tester.tap(find.descendant(
|
|
of: find.byType(WaypointListPanel),
|
|
matching: find.byIcon(Icons.edit),
|
|
));
|
|
await tester.pump();
|
|
expect(find.byType(WaypointChip), findsNWidgets(3));
|
|
|
|
await tester.drag(find.byType(WaypointChip).first, const Offset(0, -140));
|
|
// Der Chip-Flash-Effekt setzt per Timer nach 120ms zurueck - abwarten,
|
|
// damit kein Timer nach Testende/Dispose noch aussteht.
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
|
|
final waypoints = container.read(currentMissionProvider);
|
|
expect(waypoints.single.altitudeM, greaterThan(60));
|
|
});
|
|
}
|