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

@@ -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)),
),
),
),
);
}