feat(ground-suite): add local vehicle catalog
This commit is contained in:
1 parent
2fe54fb053
commit
cc9e9919db
18 files changed
+778
-6
No files matched your search
+127
@@ -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;
|
||||
}
|
||||
}
|
||||
+139
@@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user