remove experimental ground-suite project

Signed-off-by: Onur Özkan <work@onurozkan.dev>
This commit is contained in:
Onur Özkan committed 2026-08-17 19:04:39 +03:00
1 parent 43347c1172
commit 7048ed89d0
57 files changed
+1 -9881

No files matched your search

-1
View File
@@ -9,7 +9,6 @@
- [Configuration](configuration.md)
- [Logs](logs.md)
- [Errors](errors.md)
- [Ground Suite](ground-suite.md)
- [Boards](boards/index.md)
- [Kakute F7 Mini V3](boards/kakute-f7-mini-v3.md)
- [GEPRC GEP-F722-HD V2](boards/geprc-f722-v2.md)
-1
View File
@@ -24,7 +24,6 @@ target-specific commands in [Development and verification](development.md).
| `firmware/platforms/stm32f7/` | Reviewed F722 RTIC platform and peripheral ownership. |
| `firmware/libs/` | Typed math, estimation, control, safety, protocol, and driver crates. |
| `firmware/tools/sitl/` | Host-only closed-loop Gazebo simulation. |
| `ground-suite/` | Desktop ground-control application. |
| `docs/` | This Markdown documentation site. |
Do not add board-local READMEs. Add a page here instead, then link it from
-72
View File
@@ -1,72 +0,0 @@
# Ground Suite
`ground-suite/` is Kaptan's desktop Flutter application. Its primary
workspace is the Tactical Command surface: a spatial local scene, local units,
and session-local drawings and markers. The decoded diagnostic event stream is
always reachable from the tactical header.
## Application model
The core model keeps these concepts independent:
- **Vehicle definition** — immutable reviewed profile, catalog identity, and
bundled presentation media.
- **Local fleet unit** — an operator-created instance of a vehicle definition.
Local-unit IDs and in-memory planning placements belong to the workspace,
not the vehicle definition. Current placement is local presentation data and
is not yet persisted.
- **Received vehicle data** — transport input and diagnostic events. It cannot
create, position, identify, or authorize a local fleet unit.
This separation gives the fleet interface a stable path from local planning to
future live operation without coupling visual selection to incoming telemetry.
## Tactical workspace
The default route is Tactical Command. Its map-edge `UNITS`, `CURSOR`, and
`VIEW` groups are local interactions, not MAVLink controls. `Selection cursor`
is the default mode for map and rectangle selection. `Pencil cursor` creates
session-local freehand strokes; its top toolbar controls hue, saturation,
brightness, and thickness. `Marker cursor` has an independent full-color
style and creates a named local marker only after a map click and explicit
save. The shared disclosure keeps local scene data,
vehicle link state, and command authority separate. Local drawings and markers
are never reported vehicle positions.
`Add local unit` opens the horizontal local hangar. Choosing a definition only
prepares a local unit draft; the explicit setup confirmation creates and
selects a locally scattered scene unit, without asking the operator to click
the map. Local setup currently permits only the workspace label. Vehicle
configuration remains unavailable until verified binding, compatible schema,
command authority, and safe activation are available. This presentation
placement is temporary until reviewed MAVLink position data can drive a
distinct reported-position layer. The right inspector and bottom selection
console are available outside the map for keyboard and assistive-technology
access. These interactions create only session-local workspace data.
## Architecture
```text
VehicleDataPort -> typed observation updates -> observability state -> event stream
Vehicle catalog -> local-unit workspace state -> tactical scene and unit inspector
```
The transport path and local-fleet path intentionally meet only through future
explicit bindings. A future MAVLink adapter combines pinned MAVLink Common and
Kaptan dialect definitions at the decoding boundary.
## Follow-up work
- TODO(gcs-fleet-storage): versioned persistence for local units and catalog
identities.
- TODO(gcs-source-binding): explicit source-to-unit binding.
- TODO(gcs-map-pose): coordinate datum, timestamped pose, uncertainty, and
staleness handling for physical map placement.
- TODO(gcs-map-renderer): owned Linux GPU map host, pinned MapLibre assets,
and reviewed vector/DEM data source.
- TODO(gcs-platform-match): compare reported build/schema metadata to the
local definition.
- TODO(gcs-command-surface): add command interaction only with its reviewed
authority, target-binding, and safety contracts.
- TODO(gcs-catalog): generate installed definitions and category metadata from
reviewed firmware build manifests.
+1 -2
View File
@@ -12,8 +12,7 @@ Markdown.
## Index
- New contributors should read [Getting started](getting-started.md).
- Firmware source and its Rust workspace begin in `firmware/`; the desktop
application is a sibling project in `ground-suite/`.
- Firmware source and its Rust workspace begin in `firmware/`.
- Board work belongs under [Boards](boards/index.md).
- The currently supported closed-loop simulation is [Host SITL](sitl.md).
- [Configuration](configuration.md) distinguishes the current SITL profile
-4
View File
@@ -20,7 +20,3 @@ Run Cargo commands from this directory:
cd firmware
cargo test --workspace --locked --offline
```
The sibling `ground-suite/` directory is the desktop ground-control
application. It is intentionally a separate project and does not participate
in the firmware Cargo workspace.
-1
View File
@@ -26,7 +26,6 @@
pkgs.rustup
pkgs.dfu-util
pkgs.mdbook
pkgs.flutter
python
];
};
-45
View File
@@ -1,45 +0,0 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
-30
View File
@@ -1,30 +0,0 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "nixpkgs000000000000000000000000000000000"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: nixpkgs000000000000000000000000000000000
base_revision: nixpkgs000000000000000000000000000000000
- platform: linux
create_revision: nixpkgs000000000000000000000000000000000
base_revision: nixpkgs000000000000000000000000000000000
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
-24
View File
@@ -1,24 +0,0 @@
# Ground Suite
Ground Suite is Kaptan's desktop ground-control application.
## Development
From the repository root:
```sh
nix develop -c flutter analyze ground-suite
nix develop -c dart format --set-exit-if-changed ground-suite
nix develop -c sh -c 'cd ground-suite && flutter test'
nix develop -c sh -c 'cd ground-suite && flutter run -d linux'
```
## Layout
- `lib/app/` — application shell and appearance.
- `lib/features/fleet/` — fleet canvas and local-unit workspace.
- `lib/features/vehicle_workspace/` — installed vehicle definitions and Add-unit flow.
- `lib/features/diagnostics/` — diagnostic event stream.
- `lib/core/` — transport and MAVLink decoding boundaries.
The durable design and integration notes live in [the documentation](../docs/src/ground-suite.md).
-28
View File
@@ -1,28 +0,0 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

@@ -1,4 +0,0 @@
# Asset notice
Assets in this directory are project-supplied. Replacements require reviewed
ownership, provenance, and licensing.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

-20
View File
@@ -1,20 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app_shell.dart';
import 'theme.dart';
class GroundSuiteApp extends StatelessWidget {
const GroundSuiteApp({super.key});
@override
Widget build(BuildContext context) => ProviderScope(
child: MaterialApp(
title: 'Ground Suite',
debugShowCheckedModeBanner: false,
theme: groundSuiteDarkTheme(),
themeMode: ThemeMode.dark,
home: const AppShell(),
),
);
}
-71
View File
@@ -1,71 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/command/presentation/tactical_command_page.dart';
import '../features/diagnostics/presentation/diagnostics_page.dart';
import '../features/observability/presentation/observability_controller.dart';
enum _ApplicationSurface { tactical, eventStream }
/// Owns the two first-class Ground Suite surfaces. Tactical planning never
/// hides decoded diagnostics or critical accessibility announcements.
class AppShell extends ConsumerStatefulWidget {
const AppShell({super.key});
@override
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
_ApplicationSurface _surface = _ApplicationSurface.tactical;
@override
Widget build(BuildContext context) {
final observation = ref.watch(observabilityControllerProvider);
return Scaffold(
body: Stack(
children: [
switch (_surface) {
_ApplicationSurface.tactical => TacticalCommandPage(
sourceLabel: observation.sourceLabel,
sourceStatus: observation.status.label,
onOpenEventStream: () =>
setState(() => _surface = _ApplicationSurface.eventStream),
),
_ApplicationSurface.eventStream => _EventStreamWorkspace(
onReturnToTactical: () =>
setState(() => _surface = _ApplicationSurface.tactical),
),
},
Semantics(
liveRegion: true,
label: observation.accessibilityAnnouncement ?? '',
child: const SizedBox.shrink(),
),
],
),
);
}
}
class _EventStreamWorkspace extends StatelessWidget {
const _EventStreamWorkspace({required this.onReturnToTactical});
final VoidCallback onReturnToTactical;
@override
Widget build(BuildContext context) => Stack(
children: [
const Positioned.fill(child: DiagnosticsPage()),
Positioned(
top: 16,
right: 20,
child: FilledButton.icon(
onPressed: onReturnToTactical,
icon: const Icon(Icons.map_outlined),
label: const Text('Tactical workspace'),
),
),
],
);
}
-70
View File
@@ -1,70 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Ground Suite's single visual language: a dark tactical operations deck.
const panelBackground = Color(0xD912141A);
const panelBorder = Color(0x1AFFFFFF);
const textPrimary = Color(0xFFF8FAFC);
const textSecondary = Color(0xFF94A3B8);
const accentOrange = Color(0xFFF97316);
const accentMint = Color(0xFF34D399);
const statusGreen = Color(0xFF10B981);
const statusRed = Color(0xFFEF4444);
const tacticalBlack = Color(0xFF000000);
const tacticalTransparent = Color(0x00000000);
ThemeData groundSuiteDarkTheme() {
final base = ThemeData.dark(useMaterial3: true);
final interTextTheme = GoogleFonts.interTextTheme(
base.textTheme,
).apply(bodyColor: textPrimary, displayColor: textPrimary);
return base.copyWith(
scaffoldBackgroundColor: tacticalBlack,
cardColor: panelBackground,
colorScheme: const ColorScheme.dark(
primary: accentMint,
onPrimary: tacticalBlack,
secondary: accentOrange,
onSecondary: tacticalBlack,
surface: panelBackground,
onSurface: textPrimary,
error: statusRed,
onError: textPrimary,
outline: panelBorder,
),
textTheme: interTextTheme.copyWith(
displayLarge: interTextTheme.displayLarge?.copyWith(color: textPrimary),
displayMedium: interTextTheme.displayMedium?.copyWith(color: textPrimary),
headlineLarge: interTextTheme.headlineLarge?.copyWith(color: textPrimary),
headlineMedium: interTextTheme.headlineMedium?.copyWith(
color: textPrimary,
),
headlineSmall: interTextTheme.headlineSmall?.copyWith(color: textPrimary),
titleLarge: interTextTheme.titleLarge?.copyWith(color: textPrimary),
titleMedium: interTextTheme.titleMedium?.copyWith(color: textPrimary),
titleSmall: interTextTheme.titleSmall?.copyWith(color: textPrimary),
bodyLarge: interTextTheme.bodyLarge?.copyWith(color: textPrimary),
bodyMedium: interTextTheme.bodyMedium?.copyWith(color: textPrimary),
bodySmall: interTextTheme.bodySmall?.copyWith(color: textSecondary),
labelLarge: interTextTheme.labelLarge?.copyWith(color: textPrimary),
labelMedium: interTextTheme.labelMedium?.copyWith(color: textSecondary),
labelSmall: interTextTheme.labelSmall?.copyWith(color: textSecondary),
),
cardTheme: const CardThemeData(color: panelBackground, elevation: 0),
dividerTheme: const DividerThemeData(color: panelBorder),
progressIndicatorTheme: ProgressIndicatorThemeData(
color: accentMint,
linearTrackColor: textSecondary.withValues(alpha: .2),
),
chipTheme: ChipThemeData(
backgroundColor: tacticalTransparent,
selectedColor: accentMint.withValues(alpha: .1),
side: const BorderSide(color: panelBorder),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
labelStyle: GoogleFonts.inter(color: textSecondary),
secondaryLabelStyle: GoogleFonts.inter(color: textPrimary),
),
);
}
@@ -1,12 +0,0 @@
import 'dart:typed_data';
/// Receive-only bytes from one MAVLink transport endpoint.
///
/// A source has one reader and owns its close lifecycle. A future adapter will
/// bind serial, UDP, or a recording source here; no source in the first slice
/// may write bytes to a vehicle.
abstract interface class MavlinkByteSource {
Stream<Uint8List> get chunks;
Future<void> close();
}
@@ -1,13 +0,0 @@
import '../observations/vehicle_data_update.dart';
import 'mavlink_byte_source.dart';
/// Converts a receive-only MAVLink byte stream into semantically safe events.
///
/// TODO(gcs-live-mavlink): generate this adapter from pinned MAVLink Common
/// definitions plus Kaptan's `kaptan.xml` root. It must reject MAVLink 1 and
/// CRC-invalid MAVLink 2 frames, validate every fixed diagnostic length before
/// UTF-8 decoding, retain unknown enum values and flags, and emit parser or
/// semantic drop updates without ending the connection.
abstract interface class MavlinkEventDecoder {
Stream<VehicleDataUpdate> decode(MavlinkByteSource source);
}
@@ -1,136 +0,0 @@
/// A MAVLink severity value retained exactly as it appeared on the wire.
///
/// Known values follow MAVLink's numeric severity semantics. Unknown values
/// remain displayable so a newer vehicle cannot silently lose diagnostic
/// meaning before Ground Suite has been updated.
final class MavSeverity {
const MavSeverity(this.wireValue);
static const emergency = MavSeverity(0);
static const alert = MavSeverity(1);
static const critical = MavSeverity(2);
static const error = MavSeverity(3);
static const warning = MavSeverity(4);
static const notice = MavSeverity(5);
static const info = MavSeverity(6);
static const debug = MavSeverity(7);
final int wireValue;
String get label => switch (wireValue) {
0 => 'Emergency',
1 => 'Alert',
2 => 'Critical',
3 => 'Error',
4 => 'Warning',
5 => 'Notice',
6 => 'Info',
7 => 'Debug',
_ => 'Unknown ($wireValue)',
};
}
/// A KAPTAN_SHUTDOWN reason retained exactly as it appeared on the wire.
final class KaptanShutdownReason {
const KaptanShutdownReason(this.wireValue);
static const operator = KaptanShutdownReason(1);
static const safety = KaptanShutdownReason(2);
final int wireValue;
String get label => switch (wireValue) {
1 => 'Operator',
2 => 'Safety',
_ => 'Unknown ($wireValue)',
};
}
enum TelemetryKind {
rawImu,
distanceSensor,
opticalFlow;
String get label => switch (this) {
TelemetryKind.rawImu => 'Raw IMU',
TelemetryKind.distanceSensor => 'Distance sensor',
TelemetryKind.opticalFlow => 'Optical flow',
};
}
sealed class ObservedEvent {
const ObservedEvent({required this.receivedAt});
final DateTime receivedAt;
}
final class HeartbeatEvent extends ObservedEvent {
const HeartbeatEvent({
required super.receivedAt,
required this.systemId,
required this.componentId,
required this.armed,
required this.vehicleState,
});
final int systemId;
final int componentId;
final bool armed;
final String vehicleState;
}
final class TelemetryEvent extends ObservedEvent {
const TelemetryEvent({required super.receivedAt, required this.kind});
final TelemetryKind kind;
}
final class LogEvent extends ObservedEvent {
const LogEvent({
required super.receivedAt,
required this.level,
required this.message,
this.flags = 0,
});
static const traceFlag = 1;
static const truncatedFlag = 2;
static const coalescedFlag = 4;
static const knownFlags = traceFlag | truncatedFlag | coalescedFlag;
final MavSeverity level;
final String message;
final int flags;
bool get isTrace => flags & traceFlag != 0;
bool get isTruncated => flags & truncatedFlag != 0;
bool get isCoalesced => flags & coalescedFlag != 0;
int get unknownFlags => flags & ~knownFlags;
}
final class ErrorEvent extends ObservedEvent {
const ErrorEvent({
required super.receivedAt,
required this.path,
this.additionalInformation = '',
this.flags = 0,
});
static const truncatedFlag = 2;
static const coalescedFlag = 4;
static const knownFlags = truncatedFlag | coalescedFlag;
final String path;
final String additionalInformation;
final int flags;
bool get isTruncated => flags & truncatedFlag != 0;
bool get isCoalesced => flags & coalescedFlag != 0;
int get unknownFlags => flags & ~knownFlags;
}
final class ShutdownEvent extends ObservedEvent {
const ShutdownEvent({required super.receivedAt, required this.reason});
final KaptanShutdownReason reason;
}
@@ -1,16 +0,0 @@
import 'vehicle_data_update.dart';
/// A receive-only source of already-classified vehicle observations.
///
/// The replay implementation is the first concrete port. A live port may be
/// added only after the custom MAVLink decoder has its length/CRC/enum safety
/// contract and endpoint lifecycle tests.
abstract interface class VehicleDataPort {
String get label;
bool get isReplay;
Stream<VehicleDataUpdate> observe();
Future<void> close();
}
@@ -1,28 +0,0 @@
import 'observed_event.dart';
/// One receive-side outcome from a vehicle-data transport.
///
/// Expected malformed or unsupported MAVLink frames are reported as drops and
/// must not terminate the transport stream. Transport failures remain stream
/// errors because the connection lifecycle must handle them separately.
sealed class VehicleDataUpdate {
const VehicleDataUpdate();
}
/// A classified observation that is safe to present.
final class ObservationReceived extends VehicleDataUpdate {
const ObservationReceived(this.event);
final ObservedEvent event;
}
enum ObservationDropKind { parser, semantic }
/// One or more rejected frames, retained only as bounded accounting.
final class ObservationDropped extends VehicleDataUpdate {
const ObservationDropped({required this.kind, this.count = 1})
: assert(count > 0);
final ObservationDropKind kind;
final int count;
}
@@ -1,65 +0,0 @@
import '../observations/observed_event.dart';
import '../observations/vehicle_data_port.dart';
import '../observations/vehicle_data_update.dart';
/// Deterministic observations for the first Ground Suite workflow.
///
/// This is intentionally a typed replay rather than a pretend connected
/// vehicle. Raw frame replay moves behind the MAVLink decoder once bindings
/// are generated from the MAVLink Common and Kaptan dialect definitions.
final class ReplayVehicleDataPort implements VehicleDataPort {
const ReplayVehicleDataPort();
@override
String get label => 'Replay fixture';
@override
bool get isReplay => true;
@override
Stream<VehicleDataUpdate> observe() => Stream<VehicleDataUpdate>.fromIterable(
_events.map(ObservationReceived.new),
);
@override
Future<void> close() async {}
static final List<ObservedEvent> _events = [
HeartbeatEvent(
receivedAt: DateTime.utc(2026, 8, 10, 12),
systemId: 1,
componentId: 1,
armed: false,
vehicleState: 'Standby',
),
TelemetryEvent(
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 1),
kind: TelemetryKind.rawImu,
),
TelemetryEvent(
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 2),
kind: TelemetryKind.distanceSensor,
),
LogEvent(
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 3),
level: MavSeverity.warning,
message: 'Range aid stale',
flags: LogEvent.coalescedFlag,
),
ErrorEvent(
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 4),
path: 'kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
additionalInformation: 'The replay contains a bounded error detail.',
flags: ErrorEvent.truncatedFlag,
),
LogEvent(
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 5),
level: MavSeverity.info,
message: 'Replay fixture complete',
),
ShutdownEvent(
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 6),
reason: KaptanShutdownReason.operator,
),
];
}
File diff suppressed because it is too large. Load diff
@@ -1,616 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../fleet/domain/fleet_unit.dart';
import '../../vehicle_workspace/domain/vehicle_definition.dart';
/// A non-modal tactical side panel. Tool state remains in [TacticalMode], so a
/// panel can never silently become a map action.
enum TacticalPanel { fleetRoster, unitHangar, unitSetup }
/// Exactly one local map interaction can be active at a time.
sealed class TacticalMode {
const TacticalMode();
}
class TacticalBrowseMode extends TacticalMode {
const TacticalBrowseMode();
}
/// The operator's uncommitted local-unit setup. This is intentionally a panel
/// draft rather than a map tool: choosing a category never creates, connects,
/// or configures a vehicle.
class UnitCreationDraft {
const UnitCreationDraft({
required this.definition,
required this.displayName,
});
final VehicleDefinition definition;
final String displayName;
UnitCreationDraft copyWith({String? displayName}) => UnitCreationDraft(
definition: definition,
displayName: displayName ?? this.displayName,
);
}
/// Session-local freehand drawing tool. It never emits a vehicle command.
class TacticalPencilMode extends TacticalMode {
TacticalPencilMode({
this.hue = 164,
this.saturation = .78,
this.brightness = .94,
this.thickness = 3,
List<LocalScenePoint> keyboardPoints = const [],
}) : keyboardPoints = List.unmodifiable(keyboardPoints);
final double hue;
final double saturation;
final double brightness;
final double thickness;
final List<LocalScenePoint> keyboardPoints;
}
/// A local, freehand mark. It deliberately has no map pin or label: Pencil is
/// continuous ink, not a point-placement tool.
class LocalSceneDrawing {
LocalSceneDrawing({
required this.id,
required this.hue,
required this.saturation,
required this.brightness,
required this.thickness,
required List<LocalScenePoint> points,
}) : points = List.unmodifiable(points);
final String id;
final double hue;
final double saturation;
final double brightness;
final double thickness;
final List<LocalScenePoint> points;
}
/// A local point annotation. It is kept distinct from [LocalSceneDrawing] so
/// an accidental Pencil click can never become a named marker.
class TacticalMarkerMode extends TacticalMode {
const TacticalMarkerMode({
this.hue = 26,
this.saturation = .68,
this.brightness = .98,
this.pendingPoint,
});
final double hue;
final double saturation;
final double brightness;
final LocalScenePoint? pendingPoint;
}
class LocalSceneMarker {
const LocalSceneMarker({
required this.id,
required this.name,
required this.point,
required this.hue,
required this.saturation,
required this.brightness,
});
final String id;
final String name;
final LocalScenePoint point;
final double hue;
final double saturation;
final double brightness;
}
class TacticalUiState {
const TacticalUiState({
required this.selectedUnitIds,
required this.mode,
required this.panel,
required this.unitDraft,
required this.drawings,
required this.markers,
required this.selectedDrawingId,
required this.selectedMarkerId,
required this.overlays,
});
factory TacticalUiState.initial() => const TacticalUiState(
selectedUnitIds: {'DEMO-1021'},
mode: TacticalBrowseMode(),
panel: null,
unitDraft: null,
drawings: [],
markers: [],
selectedDrawingId: null,
selectedMarkerId: null,
overlays: {TacticalMapOverlay.drones},
);
final Set<String> selectedUnitIds;
final TacticalMode mode;
final TacticalPanel? panel;
final UnitCreationDraft? unitDraft;
final List<LocalSceneDrawing> drawings;
final List<LocalSceneMarker> markers;
final String? selectedDrawingId;
final String? selectedMarkerId;
final Set<TacticalMapOverlay> overlays;
TacticalUiState copyWith({
Set<String>? selectedUnitIds,
TacticalMode? mode,
TacticalPanel? panel,
bool clearPanel = false,
UnitCreationDraft? unitDraft,
bool clearUnitDraft = false,
List<LocalSceneDrawing>? drawings,
List<LocalSceneMarker>? markers,
String? selectedDrawingId,
String? selectedMarkerId,
bool clearSelectedDrawing = false,
bool clearSelectedMarker = false,
Set<TacticalMapOverlay>? overlays,
}) => TacticalUiState(
selectedUnitIds: selectedUnitIds == null
? this.selectedUnitIds
: Set.unmodifiable(selectedUnitIds),
mode: mode ?? this.mode,
panel: clearPanel ? null : panel ?? this.panel,
unitDraft: clearUnitDraft ? null : unitDraft ?? this.unitDraft,
drawings: drawings == null ? this.drawings : List.unmodifiable(drawings),
markers: markers == null ? this.markers : List.unmodifiable(markers),
selectedDrawingId: clearSelectedDrawing
? null
: selectedDrawingId ?? this.selectedDrawingId,
selectedMarkerId: clearSelectedMarker
? null
: selectedMarkerId ?? this.selectedMarkerId,
overlays: overlays == null ? this.overlays : Set.unmodifiable(overlays),
);
}
/// Only data-backed scene layers belong here. Synthetic sector, route, and
/// range-ring artwork is intentionally not part of the tactical workspace.
enum TacticalMapOverlay { drones }
final tacticalControllerProvider =
NotifierProvider<TacticalController, TacticalUiState>(
TacticalController.new,
);
/// Deterministic reducer for session-local tactical interaction state.
///
/// Agreed action taxonomy:
/// - unit selection and placement affect only a local workspace;
/// - Pencil affects only [LocalSceneDrawing] values;
/// - Marker affects only [LocalSceneMarker] values;
/// - overlay actions affect only local rendering.
///
/// This controller has no transport dependency and no method can emit MAVLink
/// or create a live vehicle identity.
class TacticalController extends Notifier<TacticalUiState> {
int _nextDrawing = 1;
int _nextMarker = 1;
@override
TacticalUiState build() => TacticalUiState.initial();
void selectUnit(String unitId, {bool toggle = false}) {
final selected = toggle ? {...state.selectedUnitIds} : <String>{};
if (toggle && selected.contains(unitId)) {
selected.remove(unitId);
} else {
selected.add(unitId);
}
state = state.copyWith(
selectedUnitIds: selected,
mode: const TacticalBrowseMode(),
clearSelectedDrawing: true,
clearSelectedMarker: true,
clearPanel: true,
);
}
/// Replaces the local visual selection after a drag-box gesture. This is a
/// scene-only selection and cannot establish a source or vehicle binding.
void selectUnits(Iterable<String> unitIds) => state = state.copyWith(
selectedUnitIds: Set.unmodifiable(unitIds.toSet()),
mode: const TacticalBrowseMode(),
clearSelectedDrawing: true,
clearSelectedMarker: true,
clearPanel: true,
);
void clearSelection() => state = state.copyWith(
selectedUnitIds: const {},
clearSelectedDrawing: true,
clearSelectedMarker: true,
);
void showFleetRoster() =>
state = state.copyWith(panel: TacticalPanel.fleetRoster);
void closePanel() => state = state.copyWith(clearPanel: true);
/// Opens the horizontal RTS hangar. The map remains in Browse mode behind
/// it; the hangar never becomes a pointer-placement tool.
void showUnitHangar() => state = state.copyWith(
mode: const TacticalBrowseMode(),
panel: TacticalPanel.unitHangar,
clearUnitDraft: true,
);
void chooseUnitDefinition(
VehicleDefinition definition, {
required String displayName,
}) => state = state.copyWith(
panel: TacticalPanel.unitHangar,
unitDraft: UnitCreationDraft(
definition: definition,
displayName: displayName,
),
);
void updateUnitDraftName(String displayName) {
final draft = state.unitDraft;
if (draft == null) {
return;
}
state = state.copyWith(unitDraft: draft.copyWith(displayName: displayName));
}
void beginUnitSetup() {
if (state.unitDraft == null) {
return;
}
state = state.copyWith(panel: TacticalPanel.unitSetup);
}
/// Returns to the catalog without discarding the selected category or its
/// local name draft. Closing the hangar is the explicit discard boundary.
void returnToUnitHangar() =>
state = state.copyWith(panel: TacticalPanel.unitHangar);
/// Opens setup for the one already-selected local workspace unit. The ID is
/// supplied by the presentation layer after it verifies the unit exists in
/// its workspace; this reducer only accepts a matching tactical selection.
void showUnitSetupForSelectedLocalUnit(String localUnitId) {
if (state.unitDraft != null ||
state.selectedUnitIds.length != 1 ||
!state.selectedUnitIds.contains(localUnitId)) {
return;
}
state = state.copyWith(
mode: const TacticalBrowseMode(),
panel: TacticalPanel.unitSetup,
);
}
void closeUnitHangar() =>
state = state.copyWith(clearPanel: true, clearUnitDraft: true);
void completeUnitPlacement(String unitId) => state = state.copyWith(
selectedUnitIds: {unitId},
mode: const TacticalBrowseMode(),
clearPanel: true,
clearUnitDraft: true,
);
void beginPencil() => state = state.copyWith(
mode: TacticalPencilMode(),
selectedUnitIds: const {},
clearSelectedDrawing: true,
clearSelectedMarker: true,
clearPanel: true,
clearUnitDraft: true,
);
void configurePencil({
double? hue,
double? saturation,
double? brightness,
double? thickness,
}) {
final mode = state.mode;
if (mode is! TacticalPencilMode) {
return;
}
state = state.copyWith(
mode: TacticalPencilMode(
hue: hue ?? mode.hue,
saturation: saturation ?? mode.saturation,
brightness: brightness ?? mode.brightness,
thickness: thickness ?? mode.thickness,
keyboardPoints: mode.keyboardPoints,
),
);
}
/// Accessible alternative to a direct pointer trace. Keyboard users add
/// points at a movable scene cursor; it never exposes a grid or location.
void addKeyboardPencilPoint(LocalScenePoint point) {
final mode = state.mode;
if (mode is! TacticalPencilMode) {
return;
}
state = state.copyWith(
mode: TacticalPencilMode(
hue: mode.hue,
saturation: mode.saturation,
brightness: mode.brightness,
thickness: mode.thickness,
keyboardPoints: [...mode.keyboardPoints, point],
),
);
}
bool get canFinishKeyboardPencil {
final mode = state.mode;
return mode is TacticalPencilMode && mode.keyboardPoints.length >= 2;
}
bool finishKeyboardPencil() {
final mode = state.mode;
if (mode is! TacticalPencilMode || !canFinishKeyboardPencil) {
return false;
}
_commitDrawing(
hue: mode.hue,
saturation: mode.saturation,
brightness: mode.brightness,
thickness: mode.thickness,
points: mode.keyboardPoints,
);
return true;
}
/// Commits a direct press-drag-release map gesture. Preview samples stay in
/// the input widget; only the simplified stroke becomes durable local scene
/// geometry here.
bool commitPencilStroke(List<LocalScenePoint> points) {
final mode = state.mode;
if (mode is! TacticalPencilMode) {
return false;
}
if (points.length < 2) {
return false;
}
_commitDrawing(
hue: mode.hue,
saturation: mode.saturation,
brightness: mode.brightness,
thickness: mode.thickness,
points: _decimateStroke(points),
);
return true;
}
void beginMarker() => state = state.copyWith(
mode: const TacticalMarkerMode(),
selectedUnitIds: const {},
clearSelectedDrawing: true,
clearSelectedMarker: true,
clearPanel: true,
clearUnitDraft: true,
);
void configureMarker({double? hue, double? saturation, double? brightness}) {
final mode = state.mode;
if (mode is! TacticalMarkerMode) {
return;
}
state = state.copyWith(
mode: TacticalMarkerMode(
hue: hue ?? mode.hue,
saturation: saturation ?? mode.saturation,
brightness: brightness ?? mode.brightness,
pendingPoint: mode.pendingPoint,
),
);
}
void chooseMarkerPoint(LocalScenePoint point) {
final mode = state.mode;
if (mode is! TacticalMarkerMode || mode.pendingPoint != null) {
return;
}
state = state.copyWith(
mode: TacticalMarkerMode(
hue: mode.hue,
saturation: mode.saturation,
brightness: mode.brightness,
pendingPoint: point,
),
);
}
/// Returns false when the label is not acceptable. Labels are local plain
/// text only, deliberately bounded before they can reach a map renderer.
bool commitMarkerName(String value) {
final mode = state.mode;
final name = value.trim();
if (mode is! TacticalMarkerMode ||
mode.pendingPoint == null ||
name.isEmpty ||
name.runes.length > 48) {
return false;
}
final marker = LocalSceneMarker(
id: 'local-marker-${_nextMarker++}',
name: name,
point: mode.pendingPoint!,
hue: mode.hue,
saturation: mode.saturation,
brightness: mode.brightness,
);
state = state.copyWith(
markers: [...state.markers, marker],
selectedMarkerId: marker.id,
clearSelectedDrawing: true,
mode: TacticalMarkerMode(
hue: mode.hue,
saturation: mode.saturation,
brightness: mode.brightness,
),
);
return true;
}
/// First cancellation closes an unfinished marker name prompt; the next
/// returns to Select. This gives Escape/right click a predictable ladder.
bool cancelActiveTool() {
final mode = state.mode;
if (mode is TacticalMarkerMode && mode.pendingPoint != null) {
state = state.copyWith(
mode: TacticalMarkerMode(
hue: mode.hue,
saturation: mode.saturation,
brightness: mode.brightness,
),
);
return false;
}
cancelMode();
return true;
}
void selectDrawing(String drawingId) => state = state.copyWith(
selectedUnitIds: const {},
selectedDrawingId: drawingId,
clearSelectedMarker: true,
mode: const TacticalBrowseMode(),
);
void selectMarker(String markerId) => state = state.copyWith(
selectedUnitIds: const {},
selectedMarkerId: markerId,
clearSelectedDrawing: true,
mode: const TacticalBrowseMode(),
);
void removeSelectedSceneObject() {
final drawing = state.selectedDrawingId;
final marker = state.selectedMarkerId;
if (drawing != null) {
state = state.copyWith(
drawings: state.drawings.where((value) => value.id != drawing).toList(),
clearSelectedDrawing: true,
);
return;
}
if (marker == null) {
return;
}
state = state.copyWith(
markers: state.markers.where((value) => value.id != marker).toList(),
clearSelectedMarker: true,
);
}
void clearSceneObjects() => state = state.copyWith(
drawings: const [],
markers: const [],
clearSelectedDrawing: true,
clearSelectedMarker: true,
);
void cancelMode() => state = state.copyWith(mode: const TacticalBrowseMode());
void toggleOverlay(TacticalMapOverlay overlay, bool visible) {
final overlays = {...state.overlays};
if (visible) {
overlays.add(overlay);
} else {
overlays.remove(overlay);
}
state = state.copyWith(overlays: overlays);
}
void resetLocalView() => state = state.copyWith(
overlays: {TacticalMapOverlay.drones},
mode: const TacticalBrowseMode(),
);
void _commitDrawing({
required double hue,
required double saturation,
required double brightness,
required double thickness,
required List<LocalScenePoint> points,
}) {
final drawing = LocalSceneDrawing(
id: 'local-drawing-${_nextDrawing++}',
hue: hue,
saturation: saturation,
brightness: brightness,
thickness: thickness,
points: List.unmodifiable(points),
);
state = state.copyWith(
drawings: [...state.drawings, drawing],
selectedDrawingId: drawing.id,
clearSelectedMarker: true,
mode: TacticalPencilMode(
hue: hue,
saturation: saturation,
brightness: brightness,
thickness: thickness,
),
);
}
// Preserve enough detail for a visibly hand-drawn line while keeping local
// session state bounded. Input preview has its own higher short-lived cap.
static const _maximumStrokePoints = 256;
List<LocalScenePoint> _decimateStroke(List<LocalScenePoint> points) {
if (points.length <= _maximumStrokePoints) {
return List.unmodifiable(points);
}
return List.unmodifiable([
for (var index = 0; index < _maximumStrokePoints; index++)
points[((points.length - 1) * index / (_maximumStrokePoints - 1))
.round()],
]);
}
}
/// Pointer preview is isolated from command/selection state. A map widget may
/// update it once per frame while a placement tool is active.
class TacticalPointerPreview {
TacticalPointerPreview({this.point, List<LocalScenePoint> stroke = const []})
: stroke = List.unmodifiable(stroke);
final LocalScenePoint? point;
final List<LocalScenePoint> stroke;
}
final tacticalPointerPreviewProvider =
NotifierProvider<TacticalPointerPreviewController, TacticalPointerPreview>(
TacticalPointerPreviewController.new,
);
class TacticalPointerPreviewController
extends Notifier<TacticalPointerPreview> {
@override
TacticalPointerPreview build() => TacticalPointerPreview();
void show(LocalScenePoint point, {List<LocalScenePoint> stroke = const []}) =>
state = TacticalPointerPreview(point: point, stroke: stroke);
void nudge(double dx, double dy) {
final current = state.point ?? const LocalScenePoint(x: .5, y: .5);
show(
LocalScenePoint(
x: (current.x + dx).clamp(0.0, 1.0),
y: (current.y + dy).clamp(0.0, 1.0),
),
);
}
void clear() => state = TacticalPointerPreview();
}
@@ -1,356 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/observations/observed_event.dart';
import '../../../shared/widgets/severity_badge.dart';
import '../../../shared/widgets/surfaces.dart';
import '../../observability/presentation/observability_controller.dart';
enum _DiagnosticFilter { all, logs, errors, shutdowns }
class DiagnosticsPage extends ConsumerStatefulWidget {
const DiagnosticsPage({super.key});
@override
ConsumerState<DiagnosticsPage> createState() => _DiagnosticsPageState();
}
class _DiagnosticsPageState extends ConsumerState<DiagnosticsPage> {
_DiagnosticFilter _filter = _DiagnosticFilter.all;
String _query = '';
@override
Widget build(BuildContext context) {
final state = ref.watch(observabilityControllerProvider);
final diagnostics = state.events
.where(_isDiagnostic)
.toList(growable: false);
final events = diagnostics.where(_matches).toList(growable: false);
return CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(28, 28, 28, 16),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ConsoleLabel('Event stream'),
const SizedBox(height: 8),
Text(
'Event timeline',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Read-only decoded observations. Diagnostic delivery can be bounded and lossy.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Text(
'Retained diagnostics ${state.events.length} · local records not retained ${state.localTimelineEvictionCount}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
const SizedBox(height: 20),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final filter in _DiagnosticFilter.values)
ChoiceChip(
label: Text(_filterLabel(filter, diagnostics)),
selected: _filter == filter,
onSelected: (_) => setState(() => _filter = filter),
),
],
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Search message, error path, or detail',
prefixIcon: Icon(Icons.search),
),
onChanged: (value) {
setState(() => _query = value.trim().toLowerCase());
},
),
],
),
),
),
if (events.isEmpty)
const SliverPadding(
padding: EdgeInsets.fromLTRB(28, 0, 28, 28),
sliver: SliverToBoxAdapter(
child: InstrumentPanel(
child: Padding(
padding: EdgeInsets.all(20),
child: Text('No events match this view.'),
),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(28, 0, 28, 28),
sliver: SliverList.builder(
itemCount: events.length,
itemBuilder: (context, index) =>
_DiagnosticRow(event: events[index]),
),
),
],
);
}
bool _matches(ObservedEvent event) {
final typeMatches = switch (_filter) {
_DiagnosticFilter.all => true,
_DiagnosticFilter.logs => event is LogEvent,
_DiagnosticFilter.errors => event is ErrorEvent,
_DiagnosticFilter.shutdowns => event is ShutdownEvent,
};
return typeMatches &&
(_query.isEmpty ||
_searchableText(event).toLowerCase().contains(_query));
}
}
class _DiagnosticRow extends StatelessWidget {
const _DiagnosticRow({required this.event});
final ObservedEvent event;
@override
Widget build(BuildContext context) {
final content = _contentFor(
event,
Theme.of(context).brightness == Brightness.dark,
);
return Semantics(
container: true,
label: content.semanticLabel,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InstrumentPanel(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(width: 3, height: 56, color: content.tone),
const SizedBox(width: 12),
SizedBox(
width: 92,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ConsoleLabel(content.kind, color: content.tone),
const SizedBox(height: 6),
Text(
_timelineTimestamp(event.receivedAt),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
],
),
),
const SizedBox(width: 8),
content.icon,
const SizedBox(width: 10),
Expanded(
child: SelectionArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
content.title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
content.detail,
style: content.monoDetail
? Theme.of(context).textTheme.bodyMedium
?.copyWith(fontFamily: 'monospace')
: null,
),
if (content.additionalDetail case final detail?) ...[
const SizedBox(height: 8),
Text(
detail,
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
),
],
),
),
),
),
);
}
}
class _DiagnosticContent {
const _DiagnosticContent({
required this.kind,
required this.tone,
required this.icon,
required this.title,
required this.detail,
required this.semanticLabel,
this.additionalDetail,
this.monoDetail = false,
});
final String kind;
final Color tone;
final Widget icon;
final String title;
final String detail;
final String? additionalDetail;
final String semanticLabel;
final bool monoDetail;
}
_DiagnosticContent _contentFor(
ObservedEvent event,
bool dark,
) => switch (event) {
LogEvent(:final level, :final message, :final flags) => _DiagnosticContent(
kind: 'Log',
tone: _toneForSeverity(level, dark),
icon: SeverityBadge(level: level),
title: level.label,
detail: message,
additionalDetail: _formatLogFlags(flags),
semanticLabel: _semanticLogLabel(level, message, flags),
),
ErrorEvent(:final path, :final additionalInformation, :final flags) =>
_DiagnosticContent(
kind: 'Error',
tone: dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
icon: Icon(
Icons.error_outline,
color: dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
),
title: 'Runtime error observed',
detail: path,
additionalDetail: _formatErrorDetail(additionalInformation, flags),
semanticLabel: _semanticErrorLabel(path, additionalInformation, flags),
monoDetail: true,
),
ShutdownEvent(:final reason) => _DiagnosticContent(
kind: 'Shutdown',
tone: dark ? const Color(0xfff7b955) : const Color(0xffb54708),
icon: Icon(
Icons.power_settings_new_outlined,
color: dark ? const Color(0xfff7b955) : const Color(0xffb54708),
),
title: 'Orderly shutdown report',
detail: 'Reason: ${reason.label}',
additionalDetail:
'Completed orderly, output-inhibited report; not a command, panic, hard-fault, or reset.',
semanticLabel: 'Orderly shutdown report: ${reason.label}',
),
_ => throw StateError('only diagnostic events reach the timeline'),
};
bool _isDiagnostic(ObservedEvent event) =>
event is LogEvent || event is ErrorEvent || event is ShutdownEvent;
String _filterLabel(_DiagnosticFilter filter, List<ObservedEvent> events) {
final count = switch (filter) {
_DiagnosticFilter.all => events.length,
_DiagnosticFilter.logs => events.whereType<LogEvent>().length,
_DiagnosticFilter.errors => events.whereType<ErrorEvent>().length,
_DiagnosticFilter.shutdowns => events.whereType<ShutdownEvent>().length,
};
final label = switch (filter) {
_DiagnosticFilter.all => 'All events',
_DiagnosticFilter.logs => 'Logs',
_DiagnosticFilter.errors => 'Errors',
_DiagnosticFilter.shutdowns => 'Shutdown reports',
};
return '$label $count';
}
String _searchableText(ObservedEvent event) => switch (event) {
LogEvent(:final level, :final message) => '${level.label} $message',
ErrorEvent(:final path, :final additionalInformation) =>
'$path $additionalInformation',
ShutdownEvent(:final reason) => reason.label,
_ => '',
};
Color _toneForSeverity(MavSeverity level, bool dark) =>
switch (level.wireValue) {
0 ||
1 ||
2 ||
3 => dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
4 => dark ? const Color(0xfff7b955) : const Color(0xffb54708),
5 || 6 => dark ? const Color(0xff70b8ff) : const Color(0xff175cd3),
7 => dark ? const Color(0xffa8b5ac) : const Color(0xff546258),
_ => dark ? const Color(0xffa8b5ac) : const Color(0xff546258),
};
String? _formatLogFlags(int flags) {
final labels = <String>[
if (flags & LogEvent.traceFlag != 0) 'Trace',
if (flags & LogEvent.truncatedFlag != 0) 'Truncated',
if (flags & LogEvent.coalescedFlag != 0) 'Coalesced',
if (flags & ~LogEvent.knownFlags != 0)
'Unknown bits 0x${(flags & ~LogEvent.knownFlags).toRadixString(16)}',
];
return labels.isEmpty ? null : 'Flags: ${labels.join(' · ')}';
}
String? _formatErrorDetail(String additionalInformation, int flags) {
final flagDetail = _formatErrorFlags(flags);
final details = <String>[
if (additionalInformation.isNotEmpty) additionalInformation,
?flagDetail,
];
return details.isEmpty ? null : details.join('\n');
}
String? _formatErrorFlags(int flags) {
final labels = <String>[
if (flags & ErrorEvent.truncatedFlag != 0) 'Truncated',
if (flags & ErrorEvent.coalescedFlag != 0) 'Coalesced',
if (flags & ~ErrorEvent.knownFlags != 0)
'Unknown bits 0x${(flags & ~ErrorEvent.knownFlags).toRadixString(16)}',
];
return labels.isEmpty ? null : 'Flags: ${labels.join(' · ')}';
}
String _semanticLogLabel(MavSeverity level, String message, int flags) {
final flagDetail = _formatLogFlags(flags);
return flagDetail == null
? '${level.label} log: $message'
: '${level.label} log: $message. $flagDetail';
}
String _semanticErrorLabel(
String path,
String additionalInformation,
int flags,
) {
final detail = _formatErrorDetail(additionalInformation, flags);
return detail == null ? 'Error: $path' : 'Error: $path. $detail';
}
String _timelineTimestamp(DateTime value) =>
'${value.hour.toString().padLeft(2, '0')}:'
'${value.minute.toString().padLeft(2, '0')}:'
'${value.second.toString().padLeft(2, '0')}Z';
@@ -1,46 +0,0 @@
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);
}
@@ -1,261 +0,0 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/observations/observed_event.dart';
import '../../../core/observations/vehicle_data_port.dart';
import '../../../core/observations/vehicle_data_update.dart';
import '../../../core/transport/replay_vehicle_data_port.dart';
final vehicleDataPortProvider = Provider<VehicleDataPort>((ref) {
final port = const ReplayVehicleDataPort();
ref.onDispose(() => unawaited(port.close()));
return port;
});
final observabilityControllerProvider =
NotifierProvider<ObservabilityController, ObservabilityState>(
ObservabilityController.new,
);
class ObservabilityController extends Notifier<ObservabilityState> {
static const _criticalAnnouncementWindow = Duration(seconds: 2);
static const _timelineCapacity = 500;
StreamSubscription<VehicleDataUpdate>? _subscription;
DateTime? _lastCriticalAnnouncementAt;
@override
ObservabilityState build() {
final source = ref.read(vehicleDataPortProvider);
ref.onDispose(_cancelSubscription);
Future<void>.microtask(() => _observe(source));
return ObservabilityState.initial(source);
}
void _observe(VehicleDataPort source) {
_subscription = source.observe().listen(
_handleUpdate,
onError: (Object _, StackTrace _) {
state = state.copyWith(
status: ObservationStatus.disconnected,
accessibilityAnnouncement: 'Observation connection closed.',
replaceAccessibilityAnnouncement: true,
);
},
onDone: () {
state = state.copyWith(
status: source.isReplay
? ObservationStatus.replayComplete
: ObservationStatus.disconnected,
accessibilityAnnouncement: source.isReplay
? 'Replay complete.'
: 'Observation connection closed.',
replaceAccessibilityAnnouncement: true,
);
},
);
}
void _handleUpdate(VehicleDataUpdate update) {
switch (update) {
case ObservationReceived(:final event):
_record(event);
case ObservationDropped(:final kind, :final count):
state = state.copyWith(
status: ObservationStatus.receiving,
parserDropCount:
state.parserDropCount +
(kind == ObservationDropKind.parser ? count : 0),
semanticDropCount:
state.semanticDropCount +
(kind == ObservationDropKind.semantic ? count : 0),
);
}
}
void _record(ObservedEvent event) {
final announcement = _announcementFor(event);
final retention = _isTimelineEvent(event)
? _retainTimelineEvent(state.events, event)
: _TimelineRetention(state.events);
final heartbeat = event is HeartbeatEvent ? event : state.lastHeartbeat;
final observedStreams = switch (event) {
TelemetryEvent(:final kind) => {...state.observedTelemetryKinds, kind},
_ => state.observedTelemetryKinds,
};
state = state.copyWith(
status: ObservationStatus.receiving,
lastFrameAt: event.receivedAt,
events: retention.events,
lastHeartbeat: heartbeat,
observedTelemetryKinds: Set.unmodifiable(observedStreams),
localTimelineEvictionCount:
state.localTimelineEvictionCount + retention.evictionCount,
accessibilityAnnouncement: announcement.message,
replaceAccessibilityAnnouncement: true,
coalescedAlertCount:
state.coalescedAlertCount + (announcement.coalesced ? 1 : 0),
);
}
_AnnouncementDecision _announcementFor(ObservedEvent event) {
final candidate = switch (event) {
ErrorEvent(:final path) => 'Error received: $path',
ShutdownEvent(:final reason) =>
'Orderly shutdown observed: ${reason.label}',
_ => null,
};
if (candidate == null) {
return const _AnnouncementDecision();
}
final now = DateTime.now();
final previous = _lastCriticalAnnouncementAt;
if (previous != null &&
now.difference(previous) < _criticalAnnouncementWindow) {
return const _AnnouncementDecision(coalesced: true);
}
_lastCriticalAnnouncementAt = now;
return _AnnouncementDecision(message: candidate);
}
void _cancelSubscription() {
_subscription?.cancel();
}
_TimelineRetention _retainTimelineEvent(
List<ObservedEvent> current,
ObservedEvent incoming,
) {
if (current.length < _timelineCapacity) {
return _TimelineRetention([...current, incoming]);
}
final retained = List<ObservedEvent>.of(current);
final optionalIndex = retained.indexWhere((event) => !_isCritical(event));
if (optionalIndex >= 0) {
retained.removeAt(optionalIndex);
retained.add(incoming);
return _TimelineRetention(retained, evictionCount: 1);
}
if (_isCritical(incoming)) {
retained.removeAt(0);
retained.add(incoming);
return _TimelineRetention(retained, evictionCount: 1);
}
return _TimelineRetention(retained, evictionCount: 1);
}
}
bool _isCritical(ObservedEvent event) =>
event is ErrorEvent || event is ShutdownEvent;
bool _isTimelineEvent(ObservedEvent event) =>
event is LogEvent || _isCritical(event);
class _TimelineRetention {
_TimelineRetention(Iterable<ObservedEvent> events, {this.evictionCount = 0})
: events = List.unmodifiable(events);
final List<ObservedEvent> events;
final int evictionCount;
}
enum ObservationStatus {
starting('Starting'),
receiving('Observing'),
replayComplete('Replay complete'),
disconnected('Disconnected');
const ObservationStatus(this.label);
final String label;
}
class ObservabilityState {
const ObservabilityState({
required this.sourceLabel,
required this.isReplay,
required this.status,
required this.events,
required this.lastHeartbeat,
required this.observedTelemetryKinds,
required this.parserDropCount,
required this.semanticDropCount,
required this.coalescedAlertCount,
required this.localTimelineEvictionCount,
this.lastFrameAt,
this.accessibilityAnnouncement,
});
factory ObservabilityState.initial(VehicleDataPort source) {
return ObservabilityState(
sourceLabel: source.label,
isReplay: source.isReplay,
status: ObservationStatus.starting,
events: const [],
lastHeartbeat: null,
observedTelemetryKinds: const {},
parserDropCount: 0,
semanticDropCount: 0,
coalescedAlertCount: 0,
localTimelineEvictionCount: 0,
);
}
final String sourceLabel;
final bool isReplay;
final ObservationStatus status;
final List<ObservedEvent> events;
final HeartbeatEvent? lastHeartbeat;
final Set<TelemetryKind> observedTelemetryKinds;
final int parserDropCount;
final int semanticDropCount;
final int coalescedAlertCount;
final int localTimelineEvictionCount;
final DateTime? lastFrameAt;
final String? accessibilityAnnouncement;
ObservabilityState copyWith({
ObservationStatus? status,
List<ObservedEvent>? events,
HeartbeatEvent? lastHeartbeat,
Set<TelemetryKind>? observedTelemetryKinds,
int? parserDropCount,
int? semanticDropCount,
int? coalescedAlertCount,
int? localTimelineEvictionCount,
DateTime? lastFrameAt,
String? accessibilityAnnouncement,
bool replaceAccessibilityAnnouncement = false,
}) {
return ObservabilityState(
sourceLabel: sourceLabel,
isReplay: isReplay,
status: status ?? this.status,
events: events ?? this.events,
lastHeartbeat: lastHeartbeat ?? this.lastHeartbeat,
observedTelemetryKinds:
observedTelemetryKinds ?? this.observedTelemetryKinds,
parserDropCount: parserDropCount ?? this.parserDropCount,
semanticDropCount: semanticDropCount ?? this.semanticDropCount,
coalescedAlertCount: coalescedAlertCount ?? this.coalescedAlertCount,
localTimelineEvictionCount:
localTimelineEvictionCount ?? this.localTimelineEvictionCount,
lastFrameAt: lastFrameAt ?? this.lastFrameAt,
accessibilityAnnouncement: replaceAccessibilityAnnouncement
? accessibilityAnnouncement
: this.accessibilityAnnouncement,
);
}
}
class _AnnouncementDecision {
const _AnnouncementDecision({this.message, this.coalesced = false});
final String? message;
final bool coalesced;
}
@@ -1,66 +0,0 @@
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',
),
),
];
@@ -1,27 +0,0 @@
/// 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;
}
@@ -1,127 +0,0 @@
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;
}
}
@@ -1,139 +0,0 @@
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)),
),
),
),
);
}
-7
View File
@@ -1,7 +0,0 @@
import 'package:flutter/widgets.dart';
import 'app/app.dart';
void main() {
runApp(const GroundSuiteApp());
}
@@ -1,41 +0,0 @@
import 'package:flutter/material.dart';
import '../../core/observations/observed_event.dart';
class SeverityBadge extends StatelessWidget {
const SeverityBadge({required this.level, super.key});
final MavSeverity level;
@override
Widget build(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
final (icon, color) = switch (level.wireValue) {
0 || 1 || 2 || 3 => (
Icons.error_outline,
dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
),
4 => (
Icons.warning_amber_outlined,
dark ? const Color(0xfff7b955) : const Color(0xffb54708),
),
5 || 6 => (
Icons.info_outline,
dark ? const Color(0xff70b8ff) : const Color(0xff175cd3),
),
7 => (
Icons.bug_report_outlined,
dark ? const Color(0xffa8b5ac) : const Color(0xff546258),
),
_ => (
Icons.help_outline,
dark ? const Color(0xffa8b5ac) : const Color(0xff546258),
),
};
return Semantics(
label: 'Severity: ${level.label}',
child: Icon(icon, color: color),
);
}
}
@@ -1,216 +0,0 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// A blurred surface for stable application chrome only.
///
/// Keep this out of scrollable content. Diagnostic rows and metric panels use
/// [InstrumentPanel], which has the same visual language without creating a
/// compositing layer per item.
class FrostedChrome extends StatelessWidget {
const FrostedChrome({
required this.child,
super.key,
this.borderRadius = BorderRadius.zero,
});
final Widget child;
final BorderRadius borderRadius;
@override
Widget build(BuildContext context) {
final highContrast = MediaQuery.maybeOf(context)?.highContrast ?? false;
final surface = _SurfaceFrame(
borderRadius: borderRadius,
chrome: true,
opaque: highContrast,
child: child,
);
if (highContrast) {
return surface;
}
return ClipRRect(
borderRadius: borderRadius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: surface,
),
);
}
}
/// An opaque, high-legibility panel for operational data and event rows.
class InstrumentPanel extends StatelessWidget {
const InstrumentPanel({
required this.child,
super.key,
this.borderRadius = const BorderRadius.all(Radius.circular(12)),
this.accent,
});
final Widget child;
final BorderRadius borderRadius;
final Color? accent;
@override
Widget build(BuildContext context) => _SurfaceFrame(
borderRadius: borderRadius,
accent: accent,
opaque: true,
child: child,
);
}
class _SurfaceFrame extends StatelessWidget {
const _SurfaceFrame({
required this.child,
required this.borderRadius,
this.chrome = false,
this.opaque = false,
this.accent,
});
final Widget child;
final BorderRadius borderRadius;
final bool chrome;
final bool opaque;
final Color? accent;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final dark = scheme.brightness == Brightness.dark;
final opacity = opaque ? 0xf6 : (dark ? 0xbf : 0xd9);
final top = dark
? Color.fromARGB(
opacity,
chrome ? 12 : 16,
chrome ? 17 : 23,
chrome ? 14 : 18,
)
: Color.fromARGB(opacity, 255, 255, 255);
final bottom = dark
? Color.fromARGB(opacity, 8, 11, 9)
: Color.fromARGB(opacity, 244, 248, 244);
final border =
accent?.withValues(alpha: 0.74) ??
scheme.outline.withValues(alpha: dark ? 0.92 : 0.82);
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [top, bottom],
),
border: Border.all(color: border),
borderRadius: borderRadius,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: dark ? 0.18 : 0.06),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: child,
);
}
}
/// The static canvas behind Ground Suite's chrome and instrument surfaces.
class AppCanvas extends StatelessWidget {
const AppCanvas({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
final base = dark ? const Color(0xff080b09) : const Color(0xfff3f6f3);
final primaryHaze = (dark ? accentMint : const Color(0xff087a3b))
.withValues(alpha: dark ? 0.075 : 0.05);
return ColoredBox(
color: base,
child: Stack(
children: [
Positioned(
top: -250,
right: -200,
child: IgnorePointer(child: _Haze(color: primaryHaze)),
),
Positioned(
bottom: -330,
left: -260,
child: IgnorePointer(
child: _Haze(color: primaryHaze.withValues(alpha: 0.55)),
),
),
Positioned.fill(child: child),
],
),
);
}
}
class _Haze extends StatelessWidget {
const _Haze({required this.color});
final Color color;
@override
Widget build(BuildContext context) => Container(
width: 600,
height: 600,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(colors: [color, Colors.transparent]),
),
);
}
class ConsoleLabel extends StatelessWidget {
const ConsoleLabel(this.text, {super.key, this.color});
final String text;
final Color? color;
@override
Widget build(BuildContext context) => Text(
text.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color ?? Theme.of(context).colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
fontWeight: FontWeight.w700,
letterSpacing: 1.25,
),
);
}
class StatusBadge extends StatelessWidget {
const StatusBadge({required this.label, super.key, this.color});
final String label;
final Color? color;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final tone = color ?? scheme.onSurfaceVariant;
return DecoratedBox(
decoration: BoxDecoration(
color: tone.withValues(alpha: 0.11),
border: Border.all(color: tone.withValues(alpha: 0.45)),
borderRadius: BorderRadius.circular(7),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: ConsoleLabel(label, color: tone),
),
);
}
}
-1
View File
@@ -1 +0,0 @@
flutter/ephemeral
-128
View File
@@ -1,128 +0,0 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "ground_suite")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "dev.kaptan.ground_suite")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
# Load bundled libraries from the lib/ directory relative to the binary.
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
# Root filesystem for cross-building.
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endif()
# Define build configuration options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
# the default top-level location.
set_target_properties(${BINARY_NAME}
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
)
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# Start with a clean build bundle directory every time.
install(CODE "
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
" COMPONENT Runtime)
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endforeach(bundled_library)
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
-88
View File
@@ -1,88 +0,0 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.10)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
# which isn't available in 3.10.
function(list_prepend LIST_NAME PREFIX)
set(NEW_LIST "")
foreach(element ${${LIST_NAME}})
list(APPEND NEW_LIST "${PREFIX}${element}")
endforeach(element)
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
endfunction()
# === Flutter Library ===
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"fl_basic_message_channel.h"
"fl_binary_codec.h"
"fl_binary_messenger.h"
"fl_dart_project.h"
"fl_engine.h"
"fl_json_message_codec.h"
"fl_json_method_codec.h"
"fl_message_codec.h"
"fl_method_call.h"
"fl_method_channel.h"
"fl_method_codec.h"
"fl_method_response.h"
"fl_plugin_registrar.h"
"fl_plugin_registry.h"
"fl_standard_message_codec.h"
"fl_standard_method_codec.h"
"fl_string_codec.h"
"fl_value.h"
"fl_view.h"
"flutter_linux.h"
)
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
target_link_libraries(flutter INTERFACE
PkgConfig::GTK
PkgConfig::GLIB
PkgConfig::GIO
)
add_dependencies(flutter flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CMAKE_CURRENT_BINARY_DIR}/_phony_
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
)
@@ -1,11 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
void fl_register_plugins(FlPluginRegistry* registry) {
}
@@ -1,15 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_
@@ -1,24 +0,0 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)
-26
View File
@@ -1,26 +0,0 @@
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add preprocessor definitions for the application ID.
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Add dependency libraries. Add any application-specific dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
-6
View File
@@ -1,6 +0,0 @@
#include "my_application.h"
int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv);
}
-148
View File
@@ -1,148 +0,0 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#include "flutter/generated_plugin_registrant.h"
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
// Called when first Flutter frame received.
static void first_frame_cb(MyApplication* self, FlView* view) {
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
}
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
GtkWindow* window =
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
// Use a header bar when running in GNOME as this is the common style used
// by applications and is the setup most users will be using (e.g. Ubuntu
// desktop).
// If running on X and not using GNOME then just use a traditional title bar
// in case the window manager does more exotic layout, e.g. tiling.
// If running on Wayland assume the header bar will work (may need changing
// if future cases occur).
gboolean use_header_bar = TRUE;
#ifdef GDK_WINDOWING_X11
GdkScreen* screen = gtk_window_get_screen(window);
if (GDK_IS_X11_SCREEN(screen)) {
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
use_header_bar = FALSE;
}
}
#endif
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "Ground Suite");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "Ground Suite");
}
gtk_window_set_default_size(window, 1280, 720);
g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(
project, self->dart_entrypoint_arguments);
FlView* view = fl_view_new(project);
GdkRGBA background_color;
// Background defaults to black, override it here if necessary, e.g. #00000000
// for transparent.
gdk_rgba_parse(&background_color, "#000000");
fl_view_set_background_color(view, &background_color);
gtk_widget_show(GTK_WIDGET(view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
// Show the window when Flutter renders.
// Requires the view to be realized so we can start rendering.
g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
self);
gtk_widget_realize(GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_widget_grab_focus(GTK_WIDGET(view));
}
// Implements GApplication::local_command_line.
static gboolean my_application_local_command_line(GApplication* application,
gchar*** arguments,
int* exit_status) {
MyApplication* self = MY_APPLICATION(application);
// Strip out the first argument as it is the binary name.
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
g_autoptr(GError) error = nullptr;
if (!g_application_register(application, nullptr, &error)) {
g_warning("Failed to register: %s", error->message);
*exit_status = 1;
return TRUE;
}
g_application_activate(application);
*exit_status = 0;
return TRUE;
}
// Implements GApplication::startup.
static void my_application_startup(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application startup.
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
}
// Implements GApplication::shutdown.
static void my_application_shutdown(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application shutdown.
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
}
// Implements GObject::dispose.
static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
}
static void my_application_class_init(MyApplicationClass* klass) {
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
G_APPLICATION_CLASS(klass)->local_command_line =
my_application_local_command_line;
G_APPLICATION_CLASS(klass)->startup = my_application_startup;
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
}
static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
// Set the program name to the application ID, which helps various systems
// like GTK and desktop environments map this running application to its
// corresponding .desktop file. This ensures better integration by allowing
// the application to be recognized beyond its binary name.
g_set_prgname(APPLICATION_ID);
return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID, "flags",
G_APPLICATION_NON_UNIQUE, nullptr));
}
@@ -1,21 +0,0 @@
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
G_DECLARE_FINAL_TYPE(MyApplication,
my_application,
MY,
APPLICATION,
GtkApplication)
/**
* my_application_new:
*
* Creates a new Flutter-based application.
*
* Returns: a new #MyApplication.
*/
MyApplication* my_application_new();
#endif // FLUTTER_MY_APPLICATION_H_
-677
View File
@@ -1,677 +0,0 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
url: "https://pub.dev"
source: hosted
version: "99.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
url: "https://pub.dev"
source: hosted
version: "12.1.0"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
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:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d"
url: "https://pub.dev"
source: hosted
version: "1.15.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
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:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "56e81e662e6e54e59b231da57e90ac0d06ac67d8e3f2273c129a37ae6282b40c"
url: "https://pub.dev"
source: hosted
version: "3.4.2"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
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:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
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:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
listen:
dependency: transitive
description:
name: listen
sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
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:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
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:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
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:
name: riverpod
sha256: "84351b3472a447f7b89f961f95c73287009d58c59d8cc1267425d995f78e50f1"
url: "https://pub.dev"
source: hosted
version: "3.4.2"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: transitive
description:
name: test
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev"
source: hosted
version: "1.31.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
test_core:
dependency: transitive
description:
name: test_core
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev"
source: hosted
version: "0.6.17"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
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:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.38.4"
-88
View File
@@ -1,88 +0,0 @@
name: ground_suite
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
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.1.0+1
environment:
sdk: ^3.12.2
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^3.4.2
google_fonts: ^6.3.2
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
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
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
@@ -1,30 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ground_suite/core/observations/observed_event.dart';
import 'package:ground_suite/core/observations/vehicle_data_update.dart';
import 'package:ground_suite/core/transport/replay_vehicle_data_port.dart';
void main() {
test('replay covers identity, telemetry, log, error, and shutdown', () async {
const port = ReplayVehicleDataPort();
final events = [
for (final update in await port.observe().toList())
if (update case ObservationReceived(:final event)) event,
];
expect(events.whereType<HeartbeatEvent>(), hasLength(1));
expect(events.whereType<TelemetryEvent>(), hasLength(2));
final logs = events.whereType<LogEvent>().toList();
final errors = events.whereType<ErrorEvent>().toList();
expect(logs, hasLength(2));
expect(errors, hasLength(1));
expect(events.whereType<ShutdownEvent>(), hasLength(1));
expect(logs.first.isCoalesced, isTrue);
expect(errors.single.isTruncated, isTrue);
});
test('unknown wire enum values remain visible', () {
expect(const MavSeverity(99).label, 'Unknown (99)');
expect(const KaptanShutdownReason(99).label, 'Unknown (99)');
});
}
@@ -1,309 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ground_suite/features/command/presentation/tactical_command_page.dart';
import 'package:ground_suite/features/command/presentation/tactical_controller.dart';
import 'package:ground_suite/features/fleet/domain/fleet_unit.dart';
import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart';
void main() {
ProviderContainer container() {
final value = ProviderContainer();
addTearDown(value.dispose);
return value;
}
test('selection and cursor modes have one deterministic owner', () {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.selectUnit('DEMO-1022');
expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
'DEMO-1022',
});
controller.selectUnit('DEMO-1023', toggle: true);
expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
'DEMO-1022',
'DEMO-1023',
});
controller.beginPencil();
expect(
scope.read(tacticalControllerProvider).mode,
isA<TacticalPencilMode>(),
);
expect(scope.read(tacticalControllerProvider).selectedUnitIds, isEmpty);
controller.showUnitHangar();
expect(
scope.read(tacticalControllerProvider).panel,
TacticalPanel.unitHangar,
);
expect(scope.read(tacticalControllerProvider).unitDraft, isNull);
final definition = supportedVehicleDefinitions.first;
controller.chooseUnitDefinition(definition, displayName: 'QUADCOPTER-01');
expect(
scope.read(tacticalControllerProvider).unitDraft?.definition,
definition,
);
controller.beginUnitSetup();
expect(
scope.read(tacticalControllerProvider).panel,
TacticalPanel.unitSetup,
);
controller.completeUnitPlacement('local-unit-1');
expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
'local-unit-1',
});
expect(scope.read(tacticalControllerProvider).unitDraft, isNull);
controller.showUnitSetupForSelectedLocalUnit('local-unit-1');
expect(
scope.read(tacticalControllerProvider).panel,
TacticalPanel.unitSetup,
);
});
test(
'hangar drafts preserve the selected type and do not mutate map state',
() {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
final before = scope.read(tacticalControllerProvider);
controller.showUnitHangar();
controller.chooseUnitDefinition(
supportedVehicleDefinitions[1],
displayName: 'Fixed wing scout',
);
controller.beginUnitSetup();
controller.updateUnitDraftName('Fixed wing north');
final editing = scope.read(tacticalControllerProvider);
expect(
identical(editing.selectedUnitIds, before.selectedUnitIds),
isTrue,
);
expect(identical(editing.drawings, before.drawings), isTrue);
expect(identical(editing.markers, before.markers), isTrue);
expect(editing.panel, TacticalPanel.unitSetup);
controller.returnToUnitHangar();
final returned = scope.read(tacticalControllerProvider);
expect(returned.panel, TacticalPanel.unitHangar);
expect(returned.unitDraft?.definition, supportedVehicleDefinitions[1]);
expect(returned.unitDraft?.displayName, 'Fixed wing north');
controller.showUnitSetupForSelectedLocalUnit('local-unit-1');
expect(
scope.read(tacticalControllerProvider).panel,
TacticalPanel.unitHangar,
reason:
'A catalog draft cannot produce an unrelated blank setup panel.',
);
},
);
test(
'editing a hangar draft does not notify the tactical render projection',
() async {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
scope.read(tacticalSceneRenderStateProvider);
var renderNotifications = 0;
final subscription = scope.listen<TacticalSceneRenderState>(
tacticalSceneRenderStateProvider,
(previous, next) => renderNotifications += 1,
fireImmediately: true,
);
addTearDown(subscription.close);
renderNotifications = 0;
controller.showUnitHangar();
await scope.pump();
controller.chooseUnitDefinition(
supportedVehicleDefinitions.first,
displayName: 'Quad local',
);
await scope.pump();
controller.updateUnitDraftName('Quad north');
await scope.pump();
expect(
renderNotifications,
1,
reason: 'Opening the hangar changes its panel visibility.',
);
controller.updateUnitDraftName('Quad north one');
await scope.pump();
expect(
renderNotifications,
1,
reason: 'Typing only updates the isolated local setup draft.',
);
},
);
test(
'direct pencil strokes are session state and require complete geometry',
() {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.beginPencil();
expect(
controller.commitPencilStroke(const [LocalScenePoint(x: .1, y: .2)]),
isFalse,
);
expect(
controller.commitPencilStroke(const [
LocalScenePoint(x: .1, y: .2),
LocalScenePoint(x: .3, y: .4),
]),
isTrue,
);
final state = scope.read(tacticalControllerProvider);
expect(state.mode, isA<TacticalPencilMode>());
expect(state.drawings, hasLength(1));
expect(state.drawings.single.thickness, 3);
controller.clearSceneObjects();
expect(scope.read(tacticalControllerProvider).drawings, isEmpty);
},
);
test('view toggles remain separate from selection and pencil tools', () {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.beginPencil();
controller.toggleOverlay(TacticalMapOverlay.drones, false);
final state = scope.read(tacticalControllerProvider);
expect(state.mode, isA<TacticalPencilMode>());
expect(state.overlays, isNot(contains(TacticalMapOverlay.drones)));
});
test('fleet panel is separate from a local map mode', () {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.showFleetRoster();
expect(
scope.read(tacticalControllerProvider).panel,
TacticalPanel.fleetRoster,
);
expect(
scope.read(tacticalControllerProvider).mode,
isA<TacticalBrowseMode>(),
);
controller.beginPencil();
expect(
scope.read(tacticalControllerProvider).mode,
isA<TacticalPencilMode>(),
);
controller.closePanel();
expect(scope.read(tacticalControllerProvider).panel, isNull);
});
test('drag selection is local only', () {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.selectUnits(['DEMO-1021', 'DEMO-1023']);
final state = scope.read(tacticalControllerProvider);
expect(state.selectedUnitIds, {'DEMO-1021', 'DEMO-1023'});
expect(state.mode, isA<TacticalBrowseMode>());
});
test('direct strokes are bounded before local scene commit', () {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.beginPencil();
final stroke = [
for (var index = 0; index < 256; index++)
LocalScenePoint(x: index / 255, y: index.isEven ? .4 : .6),
];
expect(controller.commitPencilStroke(stroke), isTrue);
expect(
scope.read(tacticalControllerProvider).drawings.single.points.length,
lessThanOrEqualTo(256),
);
});
test(
'pencil style is retained for the current cursor and committed stroke',
() {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.beginPencil();
controller.configurePencil(
hue: 282,
saturation: .22,
brightness: .38,
thickness: 8,
);
expect(
controller.commitPencilStroke(const [
LocalScenePoint(x: .2, y: .2),
LocalScenePoint(x: .4, y: .4),
]),
isTrue,
);
final state = scope.read(tacticalControllerProvider);
expect(state.mode, isA<TacticalPencilMode>());
final mode = state.mode as TacticalPencilMode;
expect(mode.hue, 282);
expect(mode.saturation, .22);
expect(mode.brightness, .38);
expect(mode.thickness, 8);
expect(state.drawings.single.hue, 282);
expect(state.drawings.single.saturation, .22);
expect(state.drawings.single.brightness, .38);
expect(state.drawings.single.thickness, 8);
},
);
test('marker names and colors are independent from pencil strokes', () {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.beginPencil();
controller.configurePencil(hue: 282, thickness: 8);
controller.beginMarker();
controller.configureMarker(hue: 26, saturation: .41, brightness: .56);
controller.chooseMarkerPoint(const LocalScenePoint(x: .4, y: .6));
expect(controller.commitMarkerName('Landing review'), isTrue);
final state = scope.read(tacticalControllerProvider);
expect(state.markers, hasLength(1));
expect(state.markers.single.name, 'Landing review');
expect(state.markers.single.hue, 26);
expect(state.markers.single.saturation, .41);
expect(state.markers.single.brightness, .56);
expect((state.mode as TacticalMarkerMode).hue, 26);
expect(state.drawings, isEmpty);
});
test(
'blank marker labels and pending marker cancellation create no marker',
() {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.beginMarker();
controller.chooseMarkerPoint(const LocalScenePoint(x: .2, y: .2));
expect(controller.commitMarkerName(' '), isFalse);
expect(controller.cancelActiveTool(), isFalse);
expect(scope.read(tacticalControllerProvider).markers, isEmpty);
expect(controller.cancelActiveTool(), isTrue);
expect(
scope.read(tacticalControllerProvider).mode,
isA<TacticalBrowseMode>(),
);
},
);
}
@@ -1,174 +0,0 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ground_suite/core/observations/observed_event.dart';
import 'package:ground_suite/core/observations/vehicle_data_port.dart';
import 'package:ground_suite/core/observations/vehicle_data_update.dart';
import 'package:ground_suite/features/observability/presentation/observability_controller.dart';
void main() {
test('coalesces rapid critical accessibility announcements', () async {
final port = _ControlledDataPort();
final container = ProviderContainer(
overrides: [vehicleDataPortProvider.overrideWithValue(port)],
);
addTearDown(container.dispose);
container.read(observabilityControllerProvider);
await Future<void>.delayed(Duration.zero);
port.events.add(
ObservationReceived(
ErrorEvent(
receivedAt: DateTime.utc(2026, 8, 10),
path: 'kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
),
),
);
await Future<void>.delayed(Duration.zero);
expect(
container.read(observabilityControllerProvider).accessibilityAnnouncement,
'Error received: kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
);
port.events.add(
ObservationReceived(
ShutdownEvent(
receivedAt: DateTime.utc(2026, 8, 10, 0, 0, 1),
reason: KaptanShutdownReason.safety,
),
),
);
await Future<void>.delayed(Duration.zero);
final state = container.read(observabilityControllerProvider);
expect(state.accessibilityAnnouncement, isNull);
expect(state.coalescedAlertCount, 1);
});
test(
'retains expected parser and semantic drops without ending observation',
() async {
final port = _ControlledDataPort();
final container = ProviderContainer(
overrides: [vehicleDataPortProvider.overrideWithValue(port)],
);
addTearDown(container.dispose);
container.read(observabilityControllerProvider);
await Future<void>.delayed(Duration.zero);
port.events.add(
const ObservationDropped(kind: ObservationDropKind.parser, count: 2),
);
port.events.add(
const ObservationDropped(kind: ObservationDropKind.semantic),
);
await Future<void>.delayed(Duration.zero);
final state = container.read(observabilityControllerProvider);
expect(state.status, ObservationStatus.receiving);
expect(state.parserDropCount, 2);
expect(state.semanticDropCount, 1);
},
);
test('treats a transport stream failure as a disconnected link', () async {
final port = _ControlledDataPort();
final container = ProviderContainer(
overrides: [vehicleDataPortProvider.overrideWithValue(port)],
);
addTearDown(container.dispose);
container.read(observabilityControllerProvider);
await Future<void>.delayed(Duration.zero);
port.events.addError(StateError('serial endpoint closed'));
await Future<void>.delayed(Duration.zero);
final state = container.read(observabilityControllerProvider);
expect(state.status, ObservationStatus.disconnected);
expect(state.parserDropCount, 0);
expect(state.semanticDropCount, 0);
});
test(
'bounds diagnostics while telemetry stays outside the timeline',
() async {
final port = _ControlledDataPort();
final container = ProviderContainer(
overrides: [vehicleDataPortProvider.overrideWithValue(port)],
);
addTearDown(container.dispose);
container.read(observabilityControllerProvider);
await Future<void>.delayed(Duration.zero);
port.events.add(
ObservationReceived(
ErrorEvent(
receivedAt: DateTime.utc(2026, 8, 10),
path: 'kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
),
),
);
port.events.add(
ObservationReceived(
HeartbeatEvent(
receivedAt: DateTime.utc(2026, 8, 10, 0, 0, 1),
systemId: 7,
componentId: 42,
armed: false,
vehicleState: 'Standby',
),
),
);
for (var index = 0; index < 501; index++) {
port.events.add(
ObservationReceived(
TelemetryEvent(
receivedAt: DateTime.utc(2026, 8, 10, 0, 0, index % 60),
kind: TelemetryKind.rawImu,
),
),
);
}
for (var index = 0; index < 500; index++) {
port.events.add(
ObservationReceived(
LogEvent(
receivedAt: DateTime.utc(2026, 8, 10, 0, 1, index % 60),
level: MavSeverity.info,
message: 'Diagnostic $index',
),
),
);
}
final state = container.read(observabilityControllerProvider);
expect(state.events, hasLength(500));
expect(state.localTimelineEvictionCount, 1);
expect(state.events.whereType<ErrorEvent>(), hasLength(1));
expect(state.lastHeartbeat?.systemId, 7);
expect(state.observedTelemetryKinds, contains(TelemetryKind.rawImu));
},
);
}
class _ControlledDataPort implements VehicleDataPort {
final events = StreamController<VehicleDataUpdate>(sync: true);
@override
bool get isReplay => false;
@override
String get label => 'Test transport';
@override
Future<void> close() => events.close();
@override
Stream<VehicleDataUpdate> observe() => events.stream;
}
@@ -1,77 +0,0 @@
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));
}
});
}
@@ -1,136 +0,0 @@
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',
);
},
);
}
-683
View File
@@ -1,683 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ground_suite/app/app.dart';
import 'package:ground_suite/app/app_shell.dart';
import 'package:ground_suite/app/theme.dart';
import 'package:ground_suite/features/command/presentation/tactical_command_page.dart';
import 'package:ground_suite/features/command/presentation/tactical_controller.dart';
import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart';
import 'package:ground_suite/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
testWidgets('desktop command dashboard opens the grouped tactical controls', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
expect(app.themeMode, ThemeMode.dark);
expect(find.text('TACTICAL COMMAND'), findsOneWidget);
expect(find.text('SIMULATED CAMERA CONCEPT'), findsOneWidget);
expect(find.text('DEMO-1021'), findsAtLeastNWidgets(1));
expect(find.byKey(const Key('units-menu')), findsOneWidget);
expect(find.byKey(const Key('cursor-menu')), findsOneWidget);
expect(find.byKey(const Key('view-menu')), findsOneWidget);
expect(find.text('UNITS'), findsNothing);
expect(find.text('MODE: SELECTION'), findsNothing);
expect(find.text('VIEW'), findsNothing);
expect(
tester.getSize(find.byKey(const Key('units-menu'))),
const Size(48, 48),
);
expect(
tester.getSize(find.byKey(const Key('cursor-menu'))),
const Size(48, 48),
);
expect(
tester.getSize(find.byKey(const Key('view-menu'))),
const Size(48, 48),
);
await tester.tap(find.byKey(const Key('units-menu')));
await tester.pump();
expect(find.text('Add local unit'), findsOneWidget);
expect(
find.text('Requires verified source-to-unit binding'),
findsOneWidget,
);
await tester.tap(find.text('Add local unit'));
await tester.pumpAndSettle();
expect(find.text('UNIT HANGAR'), findsOneWidget);
expect(find.text('LOCAL SCENE'), findsAtLeastNWidgets(1));
expect(find.byKey(const Key('unit-hangar-strip')), findsOneWidget);
expect(
find.byKey(const Key('hangar-card-kaptan.vehicle.quadcopter.local')),
findsOneWidget,
);
expect(
find.byKey(const Key('hangar-card-kaptan.vehicle.fixed-wing.local')),
findsOneWidget,
);
expect(
find.byKey(const Key('hangar-card-kaptan.vehicle.tiltrotor-vtol.local')),
findsOneWidget,
);
expect(
find.byKey(const Key('hangar-card-kaptan.vehicle.helicopter.local')),
findsOneWidget,
);
expect(
find.byKey(const Key('hangar-card-kaptan.vehicle.rover.local')),
findsOneWidget,
);
expect(
find.byKey(const Key('hangar-card-kaptan.vehicle.surface-vessel.local')),
findsOneWidget,
);
await tester.tap(
find.byKey(const Key('hangar-card-kaptan.vehicle.quadcopter.local')),
);
await tester.pumpAndSettle();
expect(find.text('PREPARE UNIT'), findsOneWidget);
expect(find.text('QUADCOPTER-01'), findsNothing);
expect(
ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider).unitDraft,
isNotNull,
);
final rectangle = await tester.startGesture(const Offset(620, 500));
await rectangle.moveTo(const Offset(760, 560));
await rectangle.up();
await tester.pumpAndSettle();
final draftState = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider);
expect(draftState.panel, TacticalPanel.unitHangar);
expect(draftState.unitDraft?.definition.name, 'Quadcopter');
await tester.tap(find.text('PREPARE UNIT'));
await tester.pumpAndSettle();
expect(find.text('UNIT SETUP'), findsOneWidget);
expect(find.text('VEHICLE CONFIGURATION UNAVAILABLE'), findsOneWidget);
expect(
find.text(
'Unavailable until verified binding, compatible schema, command authority, and safe activation are available.',
),
findsOneWidget,
);
await tester.tap(find.text('ADD TO LOCAL SCENE'));
await tester.pumpAndSettle();
expect(
ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider).mode,
isA<TacticalBrowseMode>(),
);
expect(find.text('QUADCOPTER-01'), findsAtLeastNWidgets(1));
await tester.tap(find.text('SET UP'));
await tester.pumpAndSettle();
expect(find.text('LOCAL UNIT SETUP'), findsOneWidget);
expect(find.byKey(const Key('local-unit-name-field')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('dark tactical theme is fixed and uses the palette tokens', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
final context = tester.element(find.byType(AppShell));
expect(
tester.widget<MaterialApp>(find.byType(MaterialApp)).themeMode,
ThemeMode.dark,
);
expect(Theme.of(context).brightness, Brightness.dark);
expect(Theme.of(context).cardColor, panelBackground);
expect(
tester
.widget<Text>(
find.text(
'LOCAL SCENE DATA · NO VEHICLE LINK · COMMAND AUTHORITY UNAVAILABLE',
),
)
.style
?.color,
accentOrange,
);
});
testWidgets('selection console stays local and exposes no vehicle action', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('unit-card-DEMO-1023')));
await tester.pumpAndSettle();
expect(
find.text('LOCAL VISUAL PREVIEW · NO VEHICLE ACTION'),
findsOneWidget,
);
expect(find.text('DEPLOY'), findsNothing);
expect(find.text('CONFIGURE'), findsNothing);
await tester.tap(find.text('EVENT STREAM'));
await tester.pumpAndSettle();
expect(find.text('Event timeline'), findsOneWidget);
await tester.tap(find.text('Tactical workspace'));
await tester.pumpAndSettle();
expect(find.text('TACTICAL COMMAND'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('pencil cursor draws directly on the map with its toolbar', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider.notifier).beginPencil();
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsOneWidget);
expect(find.byKey(const Key('pencil-hue')), findsOneWidget);
expect(find.byKey(const Key('pencil-saturation')), findsOneWidget);
expect(find.byKey(const Key('pencil-brightness')), findsOneWidget);
expect(find.byKey(const Key('pencil-thickness')), findsOneWidget);
await tester.tap(find.byKey(const Key('pencil-swatch-282')));
await tester.drag(
find.byKey(const Key('pencil-thickness')),
const Offset(70, 0),
);
await tester.pump();
final pencil =
ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider).mode
as TacticalPencilMode;
expect(pencil.hue, 282);
expect(pencil.thickness, greaterThan(3));
final stroke = await tester.startGesture(const Offset(620, 500));
await stroke.moveTo(const Offset(680, 540));
await tester.pump(const Duration(milliseconds: 20));
await stroke.moveTo(const Offset(740, 490));
await tester.pump(const Duration(milliseconds: 20));
await stroke.up();
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsOneWidget);
final container = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
expect(container.read(tacticalControllerProvider).drawings, hasLength(1));
expect(find.text('DRAWING'), findsNothing);
expect(
tester.getSize(find.byKey(const Key('tactical-map-overlay-painter'))),
isNot(Size.zero),
);
expect(tester.takeException(), isNull);
});
testWidgets('marker cursor names a clicked local marker separately', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('cursor-menu')));
await tester.pump();
await tester.tap(find.text('Marker cursor'));
await tester.pumpAndSettle();
expect(find.text('MARKER CURSOR'), findsOneWidget);
expect(find.byKey(const Key('marker-hue')), findsOneWidget);
expect(find.byKey(const Key('marker-saturation')), findsOneWidget);
expect(find.byKey(const Key('marker-brightness')), findsOneWidget);
await tester.tap(find.byKey(const Key('marker-swatch-208')));
await tester.tapAt(const Offset(700, 520));
await tester.pumpAndSettle();
expect(find.byKey(const Key('marker-name-field')), findsOneWidget);
await tester.enterText(
find.byKey(const Key('marker-name-field')),
'North staging note',
);
await tester.tap(find.text('SAVE MARKER'));
await tester.pumpAndSettle();
final state = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider);
expect(state.markers, hasLength(1));
expect(state.markers.single.name, 'North staging note');
expect(state.markers.single.hue, 208);
expect(state.drawings, isEmpty);
expect(find.text('North staging note'), findsAtLeastNWidgets(1));
expect(tester.takeException(), isNull);
});
testWidgets('a Pencil click alone never creates a marker or drawing', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
container.read(tacticalControllerProvider.notifier).beginPencil();
await tester.pumpAndSettle();
await tester.tapAt(const Offset(700, 520));
await tester.pumpAndSettle();
final state = container.read(tacticalControllerProvider);
expect(state.drawings, isEmpty);
expect(state.markers, isEmpty);
expect(find.byKey(const Key('marker-name-field')), findsNothing);
});
testWidgets('map marker selection synchronizes the local selection console', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('map-unit-DEMO-1023')));
await tester.pumpAndSettle();
expect(find.text('DEMO-1023'), findsAtLeastNWidgets(1));
expect(
ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider).selectedUnitIds,
{'DEMO-1023'},
);
});
testWidgets('dragging the tactical canvas creates a local group selection', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
final gesture = await tester.startGesture(const Offset(660, 520));
await gesture.moveTo(const Offset(960, 745));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
final selected = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider).selectedUnitIds;
expect(selected, containsAll(['DEMO-1021', 'DEMO-1023']));
expect(find.text('LOCAL GROUP SELECTION'), findsOneWidget);
});
testWidgets(
'map deselection and hidden demo overlays never select invisibly',
(tester) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('map-unit-DEMO-1023')));
await tester.pump();
await tester.tapAt(const Offset(600, 500));
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
expect(
container.read(tacticalControllerProvider).selectedUnitIds,
isEmpty,
);
await tester.tap(find.byKey(const Key('view-menu')));
await tester.pump();
await tester.tap(find.text('Hide Drones'));
await tester.pumpAndSettle();
final gesture = await tester.startGesture(const Offset(660, 520));
await gesture.moveTo(const Offset(960, 745));
await gesture.up();
await tester.pumpAndSettle();
expect(
container.read(tacticalControllerProvider).selectedUnitIds,
isEmpty,
);
},
);
testWidgets('selection cursor ends pencil mode and restores selection', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('cursor-menu')));
await tester.pump();
await tester.tap(find.text('Pencil cursor'));
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsOneWidget);
await tester.tap(find.text('SELECTION CURSOR'));
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsNothing);
expect(FocusManager.instance.primaryFocus, isNotNull);
expect(tester.takeException(), isNull);
});
testWidgets('hangar selection prepares a local unit before confirmation', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('units-menu')));
await tester.pump();
await tester.tap(find.text('Add local unit'));
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const Key('hangar-card-kaptan.vehicle.quadcopter.local')),
);
await tester.pumpAndSettle();
final scope = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
expect(scope.read(tacticalControllerProvider).unitDraft, isNotNull);
expect(scope.read(vehicleWorkspaceProvider).units, isEmpty);
await tester.tap(find.text('PREPARE UNIT'));
await tester.pumpAndSettle();
final name = find.byKey(const Key('local-unit-name-field'));
await tester.tap(name);
await tester.enterText(name, 'North survey');
await tester.pumpAndSettle();
await tester.tap(find.text('ADD TO LOCAL SCENE'));
await tester.pumpAndSettle();
expect(find.text('North survey'), findsAtLeastNWidgets(1));
expect(scope.read(vehicleWorkspaceProvider).units, hasLength(1));
expect(
scope.read(vehicleWorkspaceProvider).units.single.displayName,
'North survey',
);
expect(
ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider).mode,
isA<TacticalBrowseMode>(),
);
});
testWidgets('hangar keeps a draft on back and blocks an invalid local name', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('units-menu')));
await tester.pump();
await tester.tap(find.text('Add local unit'));
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const Key('hangar-card-kaptan.vehicle.fixed-wing.local')),
);
await tester.pumpAndSettle();
await tester.tap(find.text('PREPARE UNIT'));
await tester.pumpAndSettle();
final name = find.byKey(const Key('local-unit-name-field'));
await tester.tap(name);
await tester.enterText(name, ' ');
await tester.tap(find.text('ADD TO LOCAL SCENE'));
await tester.pumpAndSettle();
expect(
find.text('Enter a local unit name of up to 32 characters.'),
findsOneWidget,
);
final scope = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
expect(scope.read(vehicleWorkspaceProvider).units, isEmpty);
await tester.tap(find.text('BACK TO HANGAR'));
await tester.pumpAndSettle();
expect(find.text('FIXED-WING SELECTED'), findsOneWidget);
expect(
scope.read(tacticalControllerProvider).unitDraft?.definition.name,
'Fixed-wing',
);
});
testWidgets('hangar supports arrow selection and Escape focus return', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('units-menu')));
await tester.pump();
await tester.tap(find.text('Add local unit'));
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
final scope = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
expect(
scope.read(tacticalControllerProvider).unitDraft?.definition.name,
'Fixed-wing',
);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.text('UNIT HANGAR'), findsNothing);
final trigger = find.descendant(
of: find.byKey(const Key('units-menu')),
matching: find.byType(TextButton),
);
expect(tester.widget<TextButton>(trigger).focusNode?.hasFocus, isTrue);
});
testWidgets('hangar and setup remain usable at 200 percent text scale', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1440, 960));
tester.binding.platformDispatcher.textScaleFactorTestValue = 2;
addTearDown(() {
tester.binding.setSurfaceSize(null);
tester.binding.platformDispatcher.clearTextScaleFactorTestValue();
});
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
final scope = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
final controller = scope.read(tacticalControllerProvider.notifier);
controller.showUnitHangar();
await tester.pumpAndSettle();
expect(find.byKey(const Key('unit-hangar-strip')), findsOneWidget);
controller.chooseUnitDefinition(
supportedVehicleDefinitions.first,
displayName: 'QUADCOPTER-01',
);
controller.beginUnitSetup();
await tester.pumpAndSettle();
expect(find.text('UNIT SETUP'), findsOneWidget);
expect(find.byKey(const Key('local-unit-name-field')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('hangar stays available in the short desktop arrangement', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 760));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
final scope = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
scope.read(tacticalControllerProvider.notifier).showUnitHangar();
await tester.pumpAndSettle();
expect(find.text('UNIT HANGAR'), findsOneWidget);
expect(find.byKey(const Key('unit-hangar-strip')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('secondary click cancels the pencil cursor', (tester) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('cursor-menu')));
await tester.pump();
await tester.tap(find.text('Pencil cursor'));
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsOneWidget);
final pointer = TestPointer(42, PointerDeviceKind.mouse);
await tester.sendEventToBinding(
pointer.down(const Offset(700, 520), buttons: kSecondaryButton),
);
await tester.sendEventToBinding(pointer.up());
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('keyboard pencil keeps a visible draft before finishing', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('cursor-menu')));
await tester.pump();
await tester.tap(find.text('Pencil cursor'));
await tester.pumpAndSettle();
for (var index = 0; index < 3; index++) {
await tester.tap(find.text('ADD LOCAL POINT'));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
}
await tester.pumpAndSettle();
expect(find.text('LOCAL POINTS: 3'), findsOneWidget);
expect(find.text('FINISH DRAWING'), findsOneWidget);
await tester.tap(find.text('FINISH DRAWING'));
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsOneWidget);
});
testWidgets('compact command dashboard remains usable without overflow', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(480, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
expect(find.text('SIMULATED CAMERA CONCEPT'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('command dashboard handles 200 percent text scale', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1440, 960));
tester.binding.platformDispatcher.textScaleFactorTestValue = 2;
addTearDown(() {
tester.binding.setSurfaceSize(null);
tester.binding.platformDispatcher.clearTextScaleFactorTestValue();
});
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
expect(
find.text(
'LOCAL SCENE DATA · NO VEHICLE LINK · COMMAND AUTHORITY UNAVAILABLE',
),
findsOneWidget,
);
expect(tester.takeException(), isNull);
});
testWidgets('pencil toolbar reflows at 200 percent text scale', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1440, 960));
tester.binding.platformDispatcher.textScaleFactorTestValue = 2;
addTearDown(() {
tester.binding.setSurfaceSize(null);
tester.binding.platformDispatcher.clearTextScaleFactorTestValue();
});
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider.notifier).beginPencil();
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsOneWidget);
expect(find.byKey(const Key('pencil-thickness')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('short desktop window uses the non-overlapping command layout', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1366, 768));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
expect(find.text('SIMULATED CAMERA CONCEPT'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}