feat(ground-suite): add local vehicle catalog

This commit is contained in:
Onur Özkan committed 2026-08-13 20:02:42 +03:00
1 parent 2fe54fb053
commit cc9e9919db
18 files changed
+778 -6

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -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);
}
@@ -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>[
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',
),
),
];
@@ -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;
}
@@ -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, VehicleWorkspaceState>(
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<FleetUnit> units;
bool get hasUnits => units.isNotEmpty;
VehicleWorkspaceState copyWith({List<FleetUnit>? units}) =>
VehicleWorkspaceState(units: units ?? this.units);
}
class VehicleWorkspaceController extends Notifier<VehicleWorkspaceState> {
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;
}
}
@@ -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<VehicleCatalogCard> createState() => _VehicleCatalogCardState();
}
class _VehicleCatalogCardState extends State<VehicleCatalogCard> {
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)),
),
),
),
);
}
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
+153 -1
View File
@@ -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"
+6 -5
View File
@@ -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
@@ -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(<String>[
'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));
}
});
}
@@ -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',
);
},
);
}