diff --git a/ground-suite/assets/vehicle_catalog/photos/fixed_wing.png b/ground-suite/assets/vehicle_catalog/photos/fixed_wing.png new file mode 100644 index 0000000..dbe30c8 Binary files /dev/null and b/ground-suite/assets/vehicle_catalog/photos/fixed_wing.png differ diff --git a/ground-suite/assets/vehicle_catalog/photos/helicopter.png b/ground-suite/assets/vehicle_catalog/photos/helicopter.png new file mode 100644 index 0000000..9fae497 Binary files /dev/null and b/ground-suite/assets/vehicle_catalog/photos/helicopter.png differ diff --git a/ground-suite/assets/vehicle_catalog/photos/quadcopter.png b/ground-suite/assets/vehicle_catalog/photos/quadcopter.png new file mode 100644 index 0000000..d35e860 Binary files /dev/null and b/ground-suite/assets/vehicle_catalog/photos/quadcopter.png differ diff --git a/ground-suite/assets/vehicle_catalog/photos/rover.png b/ground-suite/assets/vehicle_catalog/photos/rover.png new file mode 100644 index 0000000..4aacacf Binary files /dev/null and b/ground-suite/assets/vehicle_catalog/photos/rover.png differ diff --git a/ground-suite/assets/vehicle_catalog/photos/surface_vessel.png b/ground-suite/assets/vehicle_catalog/photos/surface_vessel.png new file mode 100644 index 0000000..d7407c1 Binary files /dev/null and b/ground-suite/assets/vehicle_catalog/photos/surface_vessel.png differ diff --git a/ground-suite/assets/vehicle_catalog/photos/tiltrotor_vtol.png b/ground-suite/assets/vehicle_catalog/photos/tiltrotor_vtol.png new file mode 100644 index 0000000..61d2280 Binary files /dev/null and b/ground-suite/assets/vehicle_catalog/photos/tiltrotor_vtol.png differ diff --git a/ground-suite/assets/vehicle_catalog/quad_x.mp4 b/ground-suite/assets/vehicle_catalog/quad_x.mp4 deleted file mode 100644 index 32b940b..0000000 Binary files a/ground-suite/assets/vehicle_catalog/quad_x.mp4 and /dev/null differ diff --git a/ground-suite/assets/vehicle_catalog/quad_x.webp b/ground-suite/assets/vehicle_catalog/quad_x.webp deleted file mode 100644 index eeafe0e..0000000 Binary files a/ground-suite/assets/vehicle_catalog/quad_x.webp and /dev/null differ diff --git a/ground-suite/lib/features/fleet/domain/fleet_unit.dart b/ground-suite/lib/features/fleet/domain/fleet_unit.dart new file mode 100644 index 0000000..f0d75e5 --- /dev/null +++ b/ground-suite/lib/features/fleet/domain/fleet_unit.dart @@ -0,0 +1,46 @@ +import '../../vehicle_workspace/domain/vehicle_definition.dart'; + +/// An operator-created fleet unit in the local planning workspace. +/// +/// It deliberately contains no received identity, platform, authority, or +/// physical pose. Those must arrive through separate reviewed bindings later. +class FleetUnit { + const FleetUnit({ + required this.localUnitId, + required this.displayName, + required this.definition, + required this.localPlacement, + }); + + final String localUnitId; + final String displayName; + final VehicleDefinition definition; + final LocalScenePoint localPlacement; + + FleetUnit copyWith({String? displayName}) => FleetUnit( + localUnitId: localUnitId, + displayName: displayName ?? this.displayName, + definition: definition, + localPlacement: localPlacement, + ); +} + +/// A normalized placement in the operator's local tactical scene. +/// +/// This is deliberately not a geographic position, vehicle pose, or received +/// location. A future map binding will keep reported coordinates separate. +class LocalScenePoint { + const LocalScenePoint({required this.x, required this.y}) + : assert(x >= 0 && x <= 1), + assert(y >= 0 && y <= 1); + + final double x; + final double y; + + @override + bool operator ==(Object other) => + other is LocalScenePoint && other.x == x && other.y == y; + + @override + int get hashCode => Object.hash(x, y); +} diff --git a/ground-suite/lib/features/vehicle_workspace/domain/vehicle_catalog.dart b/ground-suite/lib/features/vehicle_workspace/domain/vehicle_catalog.dart new file mode 100644 index 0000000..cff1411 --- /dev/null +++ b/ground-suite/lib/features/vehicle_workspace/domain/vehicle_catalog.dart @@ -0,0 +1,66 @@ +import 'vehicle_definition.dart'; + +/// Revision of the local reviewed definition catalog. +/// +/// TODO(gcs-vehicle-catalog): generate this catalog from reviewed firmware +/// build manifests, then persist both this revision and a definition ID in a +/// versioned local vehicle workspace record. +const vehicleDefinitionCatalogRevision = 1; + +/// Local vehicle types available in the tactical scene catalog. +const supportedVehicleDefinitions = [ + VehicleDefinition( + id: 'kaptan.vehicle.quadcopter.local', + name: 'Quadcopter', + vehicleClass: 'Multirotor', + supportedProfile: 'Local scene unit', + media: VehicleCatalogMedia( + imageAssetPath: 'assets/vehicle_catalog/photos/quadcopter.png', + ), + ), + VehicleDefinition( + id: 'kaptan.vehicle.fixed-wing.local', + name: 'Fixed-wing', + vehicleClass: 'Fixed-wing', + supportedProfile: 'Local scene unit', + media: VehicleCatalogMedia( + imageAssetPath: 'assets/vehicle_catalog/photos/fixed_wing.png', + ), + ), + VehicleDefinition( + id: 'kaptan.vehicle.tiltrotor-vtol.local', + name: 'Tiltrotor VTOL', + vehicleClass: 'VTOL', + supportedProfile: 'Local scene unit', + media: VehicleCatalogMedia( + imageAssetPath: 'assets/vehicle_catalog/photos/tiltrotor_vtol.png', + ), + ), + VehicleDefinition( + id: 'kaptan.vehicle.helicopter.local', + name: 'Helicopter', + vehicleClass: 'Rotorcraft', + supportedProfile: 'Local scene unit', + media: VehicleCatalogMedia( + imageAssetPath: 'assets/vehicle_catalog/photos/helicopter.png', + ), + ), + VehicleDefinition( + id: 'kaptan.vehicle.rover.local', + name: 'Ground rover', + vehicleClass: 'Ground vehicle', + supportedProfile: 'Local scene unit', + media: VehicleCatalogMedia( + imageAssetPath: 'assets/vehicle_catalog/photos/rover.png', + ), + ), + VehicleDefinition( + id: 'kaptan.vehicle.surface-vessel.local', + name: 'Surface vessel', + vehicleClass: 'Marine vehicle', + supportedProfile: 'Local scene unit', + media: VehicleCatalogMedia( + imageAssetPath: 'assets/vehicle_catalog/photos/surface_vessel.png', + ), + ), +]; diff --git a/ground-suite/lib/features/vehicle_workspace/domain/vehicle_definition.dart b/ground-suite/lib/features/vehicle_workspace/domain/vehicle_definition.dart new file mode 100644 index 0000000..19a8428 --- /dev/null +++ b/ground-suite/lib/features/vehicle_workspace/domain/vehicle_definition.dart @@ -0,0 +1,27 @@ +/// Bundled profile image for a local vehicle definition. +class VehicleCatalogMedia { + const VehicleCatalogMedia({required this.imageAssetPath}); + + final String imageAssetPath; +} + +/// A reviewed vehicle definition that can own a Ground Suite workspace. +/// +/// It is deliberately separate from platform identity, compatibility, and +/// write authority reported by a future live vehicle source. +class VehicleDefinition { + const VehicleDefinition({ + required this.id, + required this.name, + required this.vehicleClass, + required this.supportedProfile, + required this.media, + }); + + /// Stable local identity for persistence and future build matching. + final String id; + final String name; + final String vehicleClass; + final String supportedProfile; + final VehicleCatalogMedia media; +} diff --git a/ground-suite/lib/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart b/ground-suite/lib/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart new file mode 100644 index 0000000..c44b15c --- /dev/null +++ b/ground-suite/lib/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart @@ -0,0 +1,127 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../fleet/domain/fleet_unit.dart'; +import '../domain/vehicle_definition.dart'; + +final vehicleWorkspaceProvider = + NotifierProvider( + VehicleWorkspaceController.new, + ); + +/// Local fleet workspace state. +/// +/// TODO(gcs-fleet-storage): persist local-unit IDs and reviewed definition IDs +/// in a versioned workspace store. +class VehicleWorkspaceState { + const VehicleWorkspaceState({required this.units}); + + const VehicleWorkspaceState.unselected() : units = const []; + + final List units; + + bool get hasUnits => units.isNotEmpty; + + VehicleWorkspaceState copyWith({List? units}) => + VehicleWorkspaceState(units: units ?? this.units); +} + +class VehicleWorkspaceController extends Notifier { + static const maximumLocalUnits = 64; + static const _minimumSeparationSquared = .0016; + + @override + VehicleWorkspaceState build() => const VehicleWorkspaceState.unselected(); + + /// Validation shared by the hangar draft and committed workspace updates. + /// It applies only to the operator-local display label, never a vehicle ID. + static bool isValidLocalDisplayName(String value) => + _normalizeDisplayName(value) != null; + + bool canPlace(LocalScenePoint localPlacement) => + state.units.length < maximumLocalUnits && + state.units.every((unit) { + final dx = unit.localPlacement.x - localPlacement.x; + final dy = unit.localPlacement.y - localPlacement.y; + return dx * dx + dy * dy >= _minimumSeparationSquared; + }); + + bool addUnit({ + required VehicleDefinition definition, + required LocalScenePoint localPlacement, + String? displayName, + }) { + if (!canPlace(localPlacement)) { + return false; + } + final sequence = state.units.length + 1; + final unit = FleetUnit( + localUnitId: 'local-unit-$sequence', + displayName: + _normalizeDisplayName(displayName) ?? + '${definition.name.toUpperCase()}-${sequence.toString().padLeft(2, '0')}', + definition: definition, + localPlacement: localPlacement, + ); + state = VehicleWorkspaceState(units: [...state.units, unit]); + return true; + } + + /// Creates a local unit at a deterministic, scattered scene position. + /// + /// Until a reviewed MAVLink position binding exists, this is presentation + /// placement only: it is neither a GPS position nor received vehicle data. + bool addUnitAutomatically({ + required VehicleDefinition definition, + String? displayName, + }) { + for (var attempt = 0; attempt < maximumLocalUnits; attempt++) { + final sequence = state.units.length + attempt + 1; + final placement = LocalScenePoint( + x: .14 + _fraction(sequence * .61803398875) * .72, + y: .16 + _fraction(sequence * .41421356237) * .66, + ); + if (addUnit( + definition: definition, + localPlacement: placement, + displayName: displayName, + )) { + return true; + } + } + return false; + } + + static double _fraction(double value) => value - value.floorToDouble(); + + bool renameUnit(String localUnitId, String displayName) { + final normalized = _normalizeDisplayName(displayName); + if (normalized == null) { + return false; + } + var changed = false; + final units = state.units + .map((unit) { + if (unit.localUnitId != localUnitId) { + return unit; + } + changed = true; + return unit.copyWith(displayName: normalized); + }) + .toList(growable: false); + if (!changed) { + return false; + } + state = state.copyWith(units: units); + return true; + } + + static String? _normalizeDisplayName(String? value) { + final normalized = value?.trim(); + if (normalized == null || + normalized.isEmpty || + normalized.runes.length > 32) { + return null; + } + return normalized; + } +} diff --git a/ground-suite/lib/features/vehicle_workspace/presentation/widgets/vehicle_catalog_card.dart b/ground-suite/lib/features/vehicle_workspace/presentation/widgets/vehicle_catalog_card.dart new file mode 100644 index 0000000..ddf634f --- /dev/null +++ b/ground-suite/lib/features/vehicle_workspace/presentation/widgets/vehicle_catalog_card.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +import '../../../../shared/widgets/surfaces.dart'; +import '../../domain/vehicle_definition.dart'; + +/// A compact, keyboard-selectable workspace definition tile. +/// +/// Only a reviewed [VehicleDefinition] reaches this widget. The radio control +/// changes local pending workspace state; it does not connect, configure, or +/// identify a vehicle. +class VehicleCatalogCard extends StatefulWidget { + const VehicleCatalogCard({ + super.key, + required this.definition, + required this.selected, + required this.onSelected, + }); + + final VehicleDefinition definition; + final bool selected; + final VoidCallback onSelected; + + @override + State createState() => _VehicleCatalogCardState(); +} + +class _VehicleCatalogCardState extends State { + bool _showFocus = false; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final accent = widget.selected || _showFocus ? scheme.primary : null; + + return Semantics( + button: true, + checked: widget.selected, + inMutuallyExclusiveGroup: true, + label: + '${widget.definition.name}, ${widget.definition.vehicleClass}, ' + '${widget.definition.supportedProfile}, local unit type, ' + 'local vehicle image, not telemetry', + onTap: widget.onSelected, + child: DecoratedBox( + decoration: BoxDecoration( + border: _showFocus + ? Border.all(color: scheme.primary, width: 2) + : null, + borderRadius: BorderRadius.circular(14), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + excludeFromSemantics: true, + onFocusChange: (value) => setState(() => _showFocus = value), + onTap: widget.onSelected, + borderRadius: BorderRadius.circular(12), + child: InstrumentPanel( + accent: accent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _VehicleImage(media: widget.definition.media), + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConsoleLabel('Local unit type'), + const SizedBox(height: 6), + Text( + widget.definition.name, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 2), + Text( + '${widget.definition.vehicleClass} ยท ${widget.definition.supportedProfile}', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 10), + Row( + children: [ + Icon( + widget.selected + ? Icons.radio_button_checked + : Icons.radio_button_off, + size: 18, + color: widget.selected + ? scheme.primary + : scheme.onSurfaceVariant, + ), + const SizedBox(width: 7), + Expanded( + child: Text( + widget.selected + ? 'Selected for workspace' + : 'Select for workspace', + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _VehicleImage extends StatelessWidget { + const _VehicleImage({required this.media}); + + final VehicleCatalogMedia media; + + @override + Widget build(BuildContext context) => ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(11)), + child: AspectRatio( + aspectRatio: 16 / 10, + child: Image.asset( + media.imageAssetPath, + key: Key('vehicle-image-${media.imageAssetPath}'), + fit: BoxFit.cover, + filterQuality: FilterQuality.high, + excludeFromSemantics: true, + errorBuilder: (context, error, stackTrace) => ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Center(child: Icon(Icons.image_not_supported_outlined)), + ), + ), + ), + ); +} diff --git a/ground-suite/linux/flutter/generated_plugins.cmake b/ground-suite/linux/flutter/generated_plugins.cmake index 2e1de87..be1ee3e 100644 --- a/ground-suite/linux/flutter/generated_plugins.cmake +++ b/ground-suite/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/ground-suite/pubspec.lock b/ground-suite/pubspec.lock index e86079d..1f52fa8 100644 --- a/ground-suite/pubspec.lock +++ b/ground-suite/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -105,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file: dependency: transitive description: @@ -163,6 +179,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -187,6 +227,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" leak_tracker: dependency: transitive description: @@ -275,6 +339,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" package_config: dependency: transitive description: @@ -291,6 +363,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pool: dependency: transitive description: @@ -307,6 +443,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" riverpod: dependency: transitive description: @@ -512,6 +656,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" yaml: dependency: transitive description: @@ -522,4 +674,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.2 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.38.4" diff --git a/ground-suite/pubspec.yaml b/ground-suite/pubspec.yaml index a1f2b92..fc5fea5 100644 --- a/ground-suite/pubspec.yaml +++ b/ground-suite/pubspec.yaml @@ -1,5 +1,5 @@ name: ground_suite -description: "Kaptan's desktop ground-control observability console." +description: "Kaptan's desktop ground-control workspace." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev @@ -32,6 +32,7 @@ dependencies: sdk: flutter flutter_riverpod: ^3.4.2 + google_fonts: ^6.3.2 dev_dependencies: flutter_test: @@ -55,10 +56,10 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/test.png + - assets/vehicle_catalog/ + - assets/vehicle_catalog/photos/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/ground-suite/test/features/vehicle_workspace/vehicle_catalog_test.dart b/ground-suite/test/features/vehicle_workspace/vehicle_catalog_test.dart new file mode 100644 index 0000000..1c1e803 --- /dev/null +++ b/ground-suite/test/features/vehicle_workspace/vehicle_catalog_test.dart @@ -0,0 +1,77 @@ +import 'dart:ui' show CheckedState; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart'; +import 'package:ground_suite/features/vehicle_workspace/presentation/widgets/vehicle_catalog_card.dart'; + +void main() { + test('every local vehicle type has a clear bundled profile image', () { + expect(supportedVehicleDefinitions, hasLength(6)); + expect( + supportedVehicleDefinitions.map((definition) => definition.name), + containsAll([ + 'Quadcopter', + 'Fixed-wing', + 'Tiltrotor VTOL', + 'Helicopter', + 'Ground rover', + 'Surface vessel', + ]), + ); + for (final definition in supportedVehicleDefinitions) { + expect(definition.id, isNotEmpty); + expect(definition.name, isNotEmpty); + expect(definition.media.imageAssetPath, isNotEmpty); + } + }); + + testWidgets('catalog card is a single keyboard-activatable radio control', ( + tester, + ) async { + var selections = 0; + final definition = supportedVehicleDefinitions.first; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: Center( + child: SizedBox( + width: 760, + child: VehicleCatalogCard( + definition: definition, + selected: false, + onSelected: () => selections += 1, + ), + ), + ), + ), + ), + ), + ); + + final card = find.byType(VehicleCatalogCard); + final semantics = tester.getSemantics(card); + expect(semantics.flagsCollection.isChecked, CheckedState.isFalse); + expect(semantics.flagsCollection.isInMutuallyExclusiveGroup, isTrue); + expect(semantics.label, contains('local vehicle image, not telemetry')); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(selections, 2); + }); + + testWidgets('definition images are bundled locally', (tester) async { + for (final definition in supportedVehicleDefinitions) { + final image = await rootBundle.load(definition.media.imageAssetPath); + expect(image.lengthInBytes, greaterThan(0)); + } + }); +} diff --git a/ground-suite/test/features/vehicle_workspace/vehicle_workspace_controller_test.dart b/ground-suite/test/features/vehicle_workspace/vehicle_workspace_controller_test.dart new file mode 100644 index 0000000..f709187 --- /dev/null +++ b/ground-suite/test/features/vehicle_workspace/vehicle_workspace_controller_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ground_suite/features/fleet/domain/fleet_unit.dart'; +import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart'; +import 'package:ground_suite/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart'; + +void main() { + test('a reviewed definition creates a selected local fleet unit', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final controller = container.read(vehicleWorkspaceProvider.notifier); + expect(container.read(vehicleWorkspaceProvider).hasUnits, isFalse); + + final definition = supportedVehicleDefinitions.first; + expect( + controller.addUnit( + definition: definition, + localPlacement: const LocalScenePoint(x: .35, y: .45), + displayName: 'North survey', + ), + isTrue, + ); + expect(container.read(vehicleWorkspaceProvider).hasUnits, isTrue); + expect( + container.read(vehicleWorkspaceProvider).units.single.definition, + definition, + ); + expect( + container.read(vehicleWorkspaceProvider).units.single.localUnitId, + 'local-unit-1', + ); + expect( + container.read(vehicleWorkspaceProvider).units.single.displayName, + 'North survey', + ); + + expect( + controller.addUnit( + definition: definition, + localPlacement: const LocalScenePoint(x: .72, y: .45), + ), + isTrue, + ); + expect(container.read(vehicleWorkspaceProvider).units, hasLength(2)); + expect( + container.read(vehicleWorkspaceProvider).units.last.localUnitId, + 'local-unit-2', + ); + }); + + test( + 'rejects overlapping local placements and keeps a bounded workspace', + () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final controller = container.read(vehicleWorkspaceProvider.notifier); + final definition = supportedVehicleDefinitions.first; + const first = LocalScenePoint(x: .2, y: .2); + expect( + controller.addUnit(definition: definition, localPlacement: first), + isTrue, + ); + expect( + controller.addUnit(definition: definition, localPlacement: first), + isFalse, + ); + + expect( + controller.addUnit( + definition: definition, + localPlacement: const LocalScenePoint(x: .23, y: .2), + ), + isFalse, + ); + expect( + controller.addUnit( + definition: definition, + localPlacement: const LocalScenePoint(x: .65, y: .7), + ), + isTrue, + ); + }, + ); + + test( + 'catalog additions receive a scattered local presentation placement', + () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final controller = container.read(vehicleWorkspaceProvider.notifier); + final definition = supportedVehicleDefinitions.first; + expect(controller.addUnitAutomatically(definition: definition), isTrue); + expect(controller.addUnitAutomatically(definition: definition), isTrue); + + final units = container.read(vehicleWorkspaceProvider).units; + expect(units, hasLength(2)); + expect(units.first.localPlacement, isNot(units.last.localPlacement)); + for (final unit in units) { + expect(unit.localPlacement.x, inInclusiveRange(0.0, 1.0)); + expect(unit.localPlacement.y, inInclusiveRange(0.0, 1.0)); + } + }, + ); + + test( + 'local unit names are bounded workspace data, not vehicle configuration', + () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final controller = container.read(vehicleWorkspaceProvider.notifier); + expect( + controller.addUnitAutomatically( + definition: supportedVehicleDefinitions.first, + ), + isTrue, + ); + expect( + controller.renameUnit('local-unit-1', ' Ridge observer '), + isTrue, + ); + expect( + container.read(vehicleWorkspaceProvider).units.single.displayName, + 'Ridge observer', + ); + expect(controller.renameUnit('local-unit-1', ' '), isFalse); + expect( + container.read(vehicleWorkspaceProvider).units.single.displayName, + 'Ridge observer', + ); + }, + ); +}