diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 95c867d..9b7a1ca 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -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) diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index ddda287..c94babe 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -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 diff --git a/docs/src/ground-suite.md b/docs/src/ground-suite.md deleted file mode 100644 index dc5662a..0000000 --- a/docs/src/ground-suite.md +++ /dev/null @@ -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. diff --git a/docs/src/index.md b/docs/src/index.md index 5eaff7a..f5813fd 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -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 diff --git a/firmware/README.md b/firmware/README.md index 3ad8ce8..9fe7776 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -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. diff --git a/flake.nix b/flake.nix index 1b07c2a..315bac2 100644 --- a/flake.nix +++ b/flake.nix @@ -26,7 +26,6 @@ pkgs.rustup pkgs.dfu-util pkgs.mdbook - pkgs.flutter python ]; }; diff --git a/ground-suite/.gitignore b/ground-suite/.gitignore deleted file mode 100644 index 3820a95..0000000 --- a/ground-suite/.gitignore +++ /dev/null @@ -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 diff --git a/ground-suite/.metadata b/ground-suite/.metadata deleted file mode 100644 index 2215ee3..0000000 --- a/ground-suite/.metadata +++ /dev/null @@ -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' diff --git a/ground-suite/README.md b/ground-suite/README.md deleted file mode 100644 index e7c8545..0000000 --- a/ground-suite/README.md +++ /dev/null @@ -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). diff --git a/ground-suite/analysis_options.yaml b/ground-suite/analysis_options.yaml deleted file mode 100644 index 0d29021..0000000 --- a/ground-suite/analysis_options.yaml +++ /dev/null @@ -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 diff --git a/ground-suite/assets/test.png b/ground-suite/assets/test.png deleted file mode 100644 index 6e2ea1a..0000000 Binary files a/ground-suite/assets/test.png and /dev/null differ diff --git a/ground-suite/assets/vehicle_catalog/LICENSE.md b/ground-suite/assets/vehicle_catalog/LICENSE.md deleted file mode 100644 index fa45907..0000000 --- a/ground-suite/assets/vehicle_catalog/LICENSE.md +++ /dev/null @@ -1,4 +0,0 @@ -# Asset notice - -Assets in this directory are project-supplied. Replacements require reviewed -ownership, provenance, and licensing. diff --git a/ground-suite/assets/vehicle_catalog/photos/fixed_wing.png b/ground-suite/assets/vehicle_catalog/photos/fixed_wing.png deleted file mode 100644 index dbe30c8..0000000 Binary files a/ground-suite/assets/vehicle_catalog/photos/fixed_wing.png and /dev/null differ diff --git a/ground-suite/assets/vehicle_catalog/photos/helicopter.png b/ground-suite/assets/vehicle_catalog/photos/helicopter.png deleted file mode 100644 index 9fae497..0000000 Binary files a/ground-suite/assets/vehicle_catalog/photos/helicopter.png and /dev/null differ diff --git a/ground-suite/assets/vehicle_catalog/photos/quadcopter.png b/ground-suite/assets/vehicle_catalog/photos/quadcopter.png deleted file mode 100644 index d35e860..0000000 Binary files a/ground-suite/assets/vehicle_catalog/photos/quadcopter.png and /dev/null differ diff --git a/ground-suite/assets/vehicle_catalog/photos/rover.png b/ground-suite/assets/vehicle_catalog/photos/rover.png deleted file mode 100644 index 4aacacf..0000000 Binary files a/ground-suite/assets/vehicle_catalog/photos/rover.png and /dev/null differ diff --git a/ground-suite/assets/vehicle_catalog/photos/surface_vessel.png b/ground-suite/assets/vehicle_catalog/photos/surface_vessel.png deleted file mode 100644 index d7407c1..0000000 Binary files a/ground-suite/assets/vehicle_catalog/photos/surface_vessel.png and /dev/null differ diff --git a/ground-suite/assets/vehicle_catalog/photos/tiltrotor_vtol.png b/ground-suite/assets/vehicle_catalog/photos/tiltrotor_vtol.png deleted file mode 100644 index 61d2280..0000000 Binary files a/ground-suite/assets/vehicle_catalog/photos/tiltrotor_vtol.png and /dev/null differ diff --git a/ground-suite/lib/app/app.dart b/ground-suite/lib/app/app.dart deleted file mode 100644 index 08f1468..0000000 --- a/ground-suite/lib/app/app.dart +++ /dev/null @@ -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(), - ), - ); -} diff --git a/ground-suite/lib/app/app_shell.dart b/ground-suite/lib/app/app_shell.dart deleted file mode 100644 index b733543..0000000 --- a/ground-suite/lib/app/app_shell.dart +++ /dev/null @@ -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 createState() => _AppShellState(); -} - -class _AppShellState extends ConsumerState { - _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'), - ), - ), - ], - ); -} diff --git a/ground-suite/lib/app/theme.dart b/ground-suite/lib/app/theme.dart deleted file mode 100644 index 096df14..0000000 --- a/ground-suite/lib/app/theme.dart +++ /dev/null @@ -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), - ), - ); -} diff --git a/ground-suite/lib/core/mavlink/mavlink_byte_source.dart b/ground-suite/lib/core/mavlink/mavlink_byte_source.dart deleted file mode 100644 index 3a2d39d..0000000 --- a/ground-suite/lib/core/mavlink/mavlink_byte_source.dart +++ /dev/null @@ -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 get chunks; - - Future close(); -} diff --git a/ground-suite/lib/core/mavlink/mavlink_event_decoder.dart b/ground-suite/lib/core/mavlink/mavlink_event_decoder.dart deleted file mode 100644 index 8312764..0000000 --- a/ground-suite/lib/core/mavlink/mavlink_event_decoder.dart +++ /dev/null @@ -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 decode(MavlinkByteSource source); -} diff --git a/ground-suite/lib/core/observations/observed_event.dart b/ground-suite/lib/core/observations/observed_event.dart deleted file mode 100644 index 89621a0..0000000 --- a/ground-suite/lib/core/observations/observed_event.dart +++ /dev/null @@ -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; -} diff --git a/ground-suite/lib/core/observations/vehicle_data_port.dart b/ground-suite/lib/core/observations/vehicle_data_port.dart deleted file mode 100644 index 31bcec6..0000000 --- a/ground-suite/lib/core/observations/vehicle_data_port.dart +++ /dev/null @@ -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 observe(); - - Future close(); -} diff --git a/ground-suite/lib/core/observations/vehicle_data_update.dart b/ground-suite/lib/core/observations/vehicle_data_update.dart deleted file mode 100644 index 3a83e12..0000000 --- a/ground-suite/lib/core/observations/vehicle_data_update.dart +++ /dev/null @@ -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; -} diff --git a/ground-suite/lib/core/transport/replay_vehicle_data_port.dart b/ground-suite/lib/core/transport/replay_vehicle_data_port.dart deleted file mode 100644 index 480e82f..0000000 --- a/ground-suite/lib/core/transport/replay_vehicle_data_port.dart +++ /dev/null @@ -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 observe() => Stream.fromIterable( - _events.map(ObservationReceived.new), - ); - - @override - Future close() async {} - - static final List _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, - ), - ]; -} diff --git a/ground-suite/lib/features/command/presentation/tactical_command_page.dart b/ground-suite/lib/features/command/presentation/tactical_command_page.dart deleted file mode 100644 index ce5c9f8..0000000 --- a/ground-suite/lib/features/command/presentation/tactical_command_page.dart +++ /dev/null @@ -1,4694 +0,0 @@ -import 'dart:math' as math; -import 'dart:ui'; - -import 'package:flutter/gestures.dart' show kPrimaryButton, kSecondaryButton; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; - -import '../../../app/theme.dart'; -import '../../fleet/domain/fleet_unit.dart'; -import '../../vehicle_workspace/domain/vehicle_catalog.dart'; -import '../../vehicle_workspace/domain/vehicle_definition.dart'; -import '../../vehicle_workspace/presentation/vehicle_workspace_controller.dart'; -import 'tactical_controller.dart'; - -const _fpvPlaceholder = - 'https://images.unsplash.com/photo-1470770841072-f978cf4d019e' - '?auto=format&fit=crop&w=1600&q=85'; - -const _fleet = <_DroneData>[ - _DroneData('DEMO-1021', '41.4 km/h', '547 m', 'On patrol', statusGreen, .78), - _DroneData('DEMO-1023', '39.7 km/h', '511 m', 'On route', statusGreen, .64), - _DroneData( - 'DEMO-1022', - '43.5 km/h', - '611 m', - 'Monitoring', - accentOrange, - .92, - ), - _DroneData('DEMO-1024', '—', '—', 'Offline', statusRed, .18), -]; - -const _unitMapAnchors = { - 'DEMO-1021': Alignment(.14, .18), - 'DEMO-1023': Alignment(-.08, .46), - 'DEMO-1022': Alignment(.46, -.22), - 'DEMO-1024': Alignment(-.42, -.32), -}; - -String _modeLabel(TacticalMode mode) => switch (mode) { - TacticalPencilMode() => 'PENCIL', - TacticalMarkerMode() => 'MARKER', - _ => 'SELECTION', -}; - -/// The high-density tactical surface for Ground Suite. -/// -/// The current scene values are local data only; they are deliberately kept -/// separate from the vehicle data port until a reviewed live integration lands. -class TacticalCommandPage extends ConsumerStatefulWidget { - const TacticalCommandPage({ - required this.sourceLabel, - required this.sourceStatus, - required this.onOpenEventStream, - super.key, - }); - - final String sourceLabel; - final String sourceStatus; - final VoidCallback onOpenEventStream; - - @override - ConsumerState createState() => - _TacticalCommandPageState(); -} - -class _TacticalCommandPageState extends ConsumerState { - final FocusNode _fallbackFocus = FocusNode(debugLabel: 'tactical-workspace'); - final FocusNode _unitsTriggerFocus = FocusNode(debugLabel: 'units-trigger'); - FocusNode? _toolReturnFocus; - - @override - void dispose() { - _fallbackFocus.dispose(); - _unitsTriggerFocus.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final mode = ref.watch( - tacticalControllerProvider.select((state) => state.mode), - ); - return Focus( - focusNode: _fallbackFocus, - autofocus: true, - onKeyEvent: (node, event) { - if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.escape) { - final tactical = ref.read(tacticalControllerProvider); - if (mode is! TacticalBrowseMode) { - _handleAction(_TacticalAction.cancel); - return KeyEventResult.handled; - } - if (tactical.panel == TacticalPanel.unitSetup) { - if (tactical.unitDraft != null) { - ref - .read(tacticalControllerProvider.notifier) - .returnToUnitHangar(); - } else { - _closeUnitHangar(); - } - return KeyEventResult.handled; - } - if (tactical.panel == TacticalPanel.unitHangar) { - _closeUnitHangar(); - return KeyEventResult.handled; - } - } - return KeyEventResult.ignored; - }, - child: LayoutBuilder( - builder: (context, constraints) { - final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; - // The desktop deck relies on fixed visual regions. A short desktop window - // receives the scrollable compact arrangement instead of overlapping the - // map controls and the camera panel. - final compact = - constraints.maxWidth < 1120 || - constraints.maxHeight < 820 || - textScale >= 1.25; - final editing = mode is! TacticalBrowseMode; - final content = Stack( - fit: StackFit.expand, - children: [ - const _SatelliteMapBackground(), - const Positioned.fill(child: _MapAtmosphere()), - Positioned.fill( - child: _TacticalMapLayer( - onUnitSelected: _selectUnit, - onSelectionRect: _selectUnitsInRect, - ), - ), - _TacticalDeckLayer( - compact: compact, - width: constraints.maxWidth, - height: constraints.maxHeight, - onUnitSelected: _selectUnit, - onAction: _handleAction, - sourceLabel: widget.sourceLabel, - sourceStatus: widget.sourceStatus, - onOpenEventStream: widget.onOpenEventStream, - unitsTriggerFocus: _unitsTriggerFocus, - onCloseHangar: _closeUnitHangar, - ), - ], - ); - return Stack( - fit: StackFit.expand, - children: [ - ExcludeSemantics( - excluding: editing, - child: ExcludeFocus( - excluding: editing, - child: IgnorePointer(ignoring: editing, child: content), - ), - ), - if (editing) - Positioned.fill( - child: _TacticalEditLayer( - onAction: _handleAction, - onStrokeComplete: _completeSceneStroke, - onNudgeCursor: _nudgeSceneCursor, - onCommitCursor: _commitSceneCursor, - onFinishKeyboardPencil: _finishKeyboardPencil, - onConfigurePencil: _configurePencil, - onConfigureMarker: _configureMarker, - onCommitMarkerName: _commitMarkerName, - ), - ), - ], - ); - }, - ), - ); - } - - void _closeUnitHangar() { - ref.read(tacticalControllerProvider.notifier).closeUnitHangar(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _unitsTriggerFocus.requestFocus(); - } - }); - } - - void _selectUnit(String unitId) { - // A draft owns an explicit Close/Back boundary. Map selection must never - // silently dispose a typed local name or selected category. - if (ref.read(tacticalControllerProvider).unitDraft != null) { - return; - } - if (unitId.isEmpty) { - ref.read(tacticalControllerProvider.notifier).clearSelection(); - return; - } - final shiftPressed = HardwareKeyboard.instance.logicalKeysPressed.any( - (key) => - key == LogicalKeyboardKey.shiftLeft || - key == LogicalKeyboardKey.shiftRight, - ); - ref - .read(tacticalControllerProvider.notifier) - .selectUnit(unitId, toggle: shiftPressed); - } - - void _selectUnitsInRect(LocalScenePoint start, LocalScenePoint end) { - // Like a direct marker click, a drag-selection cannot quietly close an - // in-progress local setup. Close/Back is the draft's only exit boundary. - if (ref.read(tacticalControllerProvider).unitDraft != null) { - return; - } - final left = math.min(start.x, end.x); - final right = math.max(start.x, end.x); - final top = math.min(start.y, end.y); - final bottom = math.max(start.y, end.y); - bool contains(LocalScenePoint point) => - point.x >= left && - point.x <= right && - point.y >= top && - point.y <= bottom; - final showDemoUnits = ref - .read(tacticalControllerProvider) - .overlays - .contains(TacticalMapOverlay.drones); - final demoIds = (showDemoUnits ? _fleet : const <_DroneData>[]) - .where( - (unit) => contains( - LocalScenePoint( - x: (_unitMapAnchors[unit.id]!.x + 1) / 2, - y: (_unitMapAnchors[unit.id]!.y + 1) / 2, - ), - ), - ) - .map((unit) => unit.id); - final localIds = ref - .read(vehicleWorkspaceProvider) - .units - .where( - (unit) => contains( - LocalScenePoint(x: unit.localPlacement.x, y: unit.localPlacement.y), - ), - ) - .map((unit) => unit.localUnitId); - ref.read(tacticalControllerProvider.notifier).selectUnits({ - ...demoIds, - ...localIds, - }); - } - - Future _handleAction(_TacticalAction action) async { - final controller = ref.read(tacticalControllerProvider.notifier); - switch (action) { - case _TacticalAction.addUnit: - controller.showUnitHangar(); - case _TacticalAction.openFleet: - controller.showFleetRoster(); - case _TacticalAction.closeFleet: - controller.closePanel(); - case _TacticalAction.openUnitSetup: - final selectedIds = ref - .read(tacticalControllerProvider) - .selectedUnitIds; - final selectedId = selectedIds.length == 1 ? selectedIds.single : null; - if (selectedId != null && - ref - .read(vehicleWorkspaceProvider) - .units - .any((unit) => unit.localUnitId == selectedId)) { - controller.showUnitSetupForSelectedLocalUnit(selectedId); - } - case _TacticalAction.closeUnitHangar: - _closeUnitHangar(); - case _TacticalAction.clearSelection: - controller.clearSelection(); - case _TacticalAction.resetView: - controller.resetLocalView(); - case _TacticalAction.selectTool: - ref.read(tacticalPointerPreviewProvider.notifier).clear(); - controller.cancelMode(); - _restoreToolFocus(); - case _TacticalAction.pencilTool: - _beginTool(controller.beginPencil); - case _TacticalAction.markerTool: - _beginTool(controller.beginMarker); - case _TacticalAction.clearSceneObjects: - if (await _confirmClearLocalOverlays()) { - controller.clearSceneObjects(); - } - case _TacticalAction.removeSelectedSceneObject: - controller.removeSelectedSceneObject(); - case _TacticalAction.cancel: - _cancelActiveTool(); - } - } - - void _beginTool(VoidCallback begin) { - _toolReturnFocus = FocusManager.instance.primaryFocus; - begin(); - } - - void _restoreToolFocus() { - final returnFocus = _toolReturnFocus; - _toolReturnFocus = null; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { - return; - } - if (returnFocus?.context != null && - (returnFocus?.canRequestFocus ?? false)) { - returnFocus!.requestFocus(); - } else { - _fallbackFocus.requestFocus(); - } - }); - } - - Future _confirmClearLocalOverlays() async => - await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Clear local overlays?'), - content: const Text( - 'This removes all session-only local pencil drawings and markers. It does not affect any vehicle.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: const Text('Keep local scene'), - ), - FilledButton( - onPressed: () => Navigator.pop(dialogContext, true), - child: const Text('Clear drawings and markers'), - ), - ], - ), - ) ?? - false; - - void _completeSceneStroke(List points) { - if (ref - .read(tacticalControllerProvider.notifier) - .commitPencilStroke(points)) { - ref.read(tacticalPointerPreviewProvider.notifier).clear(); - } - } - - void _nudgeSceneCursor(double dx, double dy) => - ref.read(tacticalPointerPreviewProvider.notifier).nudge(dx, dy); - - void _commitSceneCursor() { - final point = - ref.read(tacticalPointerPreviewProvider).point ?? - const LocalScenePoint(x: .5, y: .5); - final mode = ref.read(tacticalControllerProvider).mode; - final controller = ref.read(tacticalControllerProvider.notifier); - if (mode is TacticalPencilMode) { - controller.addKeyboardPencilPoint(point); - return; - } - if (mode is TacticalMarkerMode) { - controller.chooseMarkerPoint(point); - return; - } - return; - } - - void _finishKeyboardPencil() { - if (ref.read(tacticalControllerProvider.notifier).finishKeyboardPencil()) { - ref.read(tacticalPointerPreviewProvider.notifier).clear(); - } - } - - void _configurePencil({ - double? hue, - double? saturation, - double? brightness, - double? thickness, - }) => ref - .read(tacticalControllerProvider.notifier) - .configurePencil( - hue: hue, - saturation: saturation, - brightness: brightness, - thickness: thickness, - ); - - void _configureMarker({ - double? hue, - double? saturation, - double? brightness, - }) => ref - .read(tacticalControllerProvider.notifier) - .configureMarker( - hue: hue, - saturation: saturation, - brightness: brightness, - ); - - void _commitMarkerName(String value) => - ref.read(tacticalControllerProvider.notifier).commitMarkerName(value); - - void _cancelActiveTool() { - ref.read(tacticalPointerPreviewProvider.notifier).clear(); - if (ref.read(tacticalControllerProvider.notifier).cancelActiveTool()) { - _restoreToolFocus(); - } - } -} - -/// Owns only map-derived state. The base map and tactical chrome are outside -/// this consumer, so selection/annotation changes do not rebuild either. -class _TacticalMapLayer extends ConsumerWidget { - const _TacticalMapLayer({ - required this.onUnitSelected, - required this.onSelectionRect, - }); - - final ValueChanged onUnitSelected; - final void Function(LocalScenePoint start, LocalScenePoint end) - onSelectionRect; - - @override - Widget build(BuildContext context, WidgetRef ref) { - // Keep local-name typing in the hangar from rebuilding this renderer. The - // selected record consists only of map-visible state and retains identity - // when a panel draft changes. - final tactical = ref.watch( - tacticalControllerProvider.select( - (state) => ( - selectedUnitIds: state.selectedUnitIds, - mode: state.mode, - drawings: state.drawings, - markers: state.markers, - overlays: state.overlays, - ), - ), - ); - final localUnits = ref.watch( - vehicleWorkspaceProvider.select((workspace) => workspace.units), - ); - return _DemoMapOverlays( - layers: _layerNames(tactical.overlays), - drawings: tactical.drawings, - markers: tactical.markers, - draftPencil: tactical.mode is TacticalPencilMode - ? tactical.mode as TacticalPencilMode - : null, - draftMarker: tactical.mode is TacticalMarkerMode - ? tactical.mode as TacticalMarkerMode - : null, - localUnits: localUnits, - selectedUnitIds: tactical.selectedUnitIds, - onUnitSelected: onUnitSelected, - onSelectionRect: onSelectionRect, - onMarkerSelected: ref - .read(tacticalControllerProvider.notifier) - .selectMarker, - ); - } -} - -/// Owns the dynamic deck data separately from the map renderer. Individual -/// scene visuals are isolated by RepaintBoundary and their own consumers. -class _TacticalDeckLayer extends ConsumerWidget { - const _TacticalDeckLayer({ - required this.compact, - required this.width, - required this.height, - required this.onUnitSelected, - required this.onAction, - required this.sourceLabel, - required this.sourceStatus, - required this.onOpenEventStream, - required this.unitsTriggerFocus, - required this.onCloseHangar, - }); - - final bool compact; - final double width; - final double height; - final ValueChanged onUnitSelected; - final ValueChanged<_TacticalAction> onAction; - final String sourceLabel; - final String sourceStatus; - final VoidCallback onOpenEventStream; - final FocusNode unitsTriggerFocus; - final VoidCallback onCloseHangar; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final tactical = ref.watch(tacticalSceneRenderStateProvider); - final workspaceUnits = ref.watch( - vehicleWorkspaceProvider.select((workspace) => workspace.units), - ); - final selectedId = tactical.selectedUnitIds.length == 1 - ? tactical.selectedUnitIds.single - : null; - _DroneData? selectedUnit; - if (selectedId != null) { - for (final unit in _fleet) { - if (unit.id == selectedId) { - selectedUnit = unit; - break; - } - } - } - final layers = _layerNames(tactical.overlays); - if (compact) { - return _CompactCommandDeck( - layers: layers, - onLayerChanged: (label, visible) => ref - .read(tacticalControllerProvider.notifier) - .toggleOverlay(_overlayForLabel(label), visible), - selectedUnit: selectedUnit, - selectedUnitIds: tactical.selectedUnitIds, - onUnitSelected: onUnitSelected, - tactical: tactical, - workspaceUnits: workspaceUnits, - onAction: onAction, - sourceLabel: sourceLabel, - sourceStatus: sourceStatus, - onOpenEventStream: onOpenEventStream, - unitsTriggerFocus: unitsTriggerFocus, - onCloseHangar: onCloseHangar, - ); - } - return _DesktopCommandDeck( - width: width, - height: height, - layers: layers, - onLayerChanged: (label, visible) => ref - .read(tacticalControllerProvider.notifier) - .toggleOverlay(_overlayForLabel(label), visible), - selectedUnit: selectedUnit, - selectedUnitIds: tactical.selectedUnitIds, - onUnitSelected: onUnitSelected, - tactical: tactical, - workspaceUnits: workspaceUnits, - onAction: onAction, - sourceLabel: sourceLabel, - sourceStatus: sourceStatus, - onOpenEventStream: onOpenEventStream, - unitsTriggerFocus: unitsTriggerFocus, - onCloseHangar: onCloseHangar, - ); - } -} - -/// The deck's rendering contract excludes the uncommitted hangar draft. This -/// lets a name-field update rebuild only [_UnitHangarDock], not FPV, chrome, -/// map controls, or the selection console. Fields are compared by identity: -/// the tactical reducer deliberately preserves unchanged immutable records. -class TacticalSceneRenderState { - const TacticalSceneRenderState({ - required this.selectedUnitIds, - required this.mode, - required this.panel, - required this.drawings, - required this.markers, - required this.selectedDrawingId, - required this.selectedMarkerId, - required this.overlays, - }); - - factory TacticalSceneRenderState.fromUiState(TacticalUiState state) => - TacticalSceneRenderState( - selectedUnitIds: state.selectedUnitIds, - mode: state.mode, - panel: state.panel, - drawings: state.drawings, - markers: state.markers, - selectedDrawingId: state.selectedDrawingId, - selectedMarkerId: state.selectedMarkerId, - overlays: state.overlays, - ); - - final Set selectedUnitIds; - final TacticalMode mode; - final TacticalPanel? panel; - final List drawings; - final List markers; - final String? selectedDrawingId; - final String? selectedMarkerId; - final Set overlays; - - @override - bool operator ==(Object other) => - other is TacticalSceneRenderState && - identical(selectedUnitIds, other.selectedUnitIds) && - identical(mode, other.mode) && - panel == other.panel && - identical(drawings, other.drawings) && - identical(markers, other.markers) && - selectedDrawingId == other.selectedDrawingId && - selectedMarkerId == other.selectedMarkerId && - identical(overlays, other.overlays); - - @override - int get hashCode => Object.hash( - identityHashCode(selectedUnitIds), - identityHashCode(mode), - panel, - identityHashCode(drawings), - identityHashCode(markers), - selectedDrawingId, - selectedMarkerId, - identityHashCode(overlays), - ); -} - -/// Projection consumed by static tactical chrome. It intentionally omits the -/// uncommitted [UnitCreationDraft], enabling local setup edits without a -/// map/deck rebuild. -final tacticalSceneRenderStateProvider = Provider( - (ref) => TacticalSceneRenderState.fromUiState( - ref.watch(tacticalControllerProvider), - ), -); - -class _TacticalEditLayer extends ConsumerWidget { - const _TacticalEditLayer({ - required this.onAction, - required this.onStrokeComplete, - required this.onNudgeCursor, - required this.onCommitCursor, - required this.onFinishKeyboardPencil, - required this.onConfigurePencil, - required this.onConfigureMarker, - required this.onCommitMarkerName, - }); - - final ValueChanged<_TacticalAction> onAction; - final ValueChanged> onStrokeComplete; - final void Function(double dx, double dy) onNudgeCursor; - final VoidCallback onCommitCursor; - final VoidCallback onFinishKeyboardPencil; - final void Function({ - double? hue, - double? saturation, - double? brightness, - double? thickness, - }) - onConfigurePencil; - final void Function({double? hue, double? saturation, double? brightness}) - onConfigureMarker; - final ValueChanged onCommitMarkerName; - - @override - Widget build(BuildContext context, WidgetRef ref) => _TacticalEditOverlay( - tactical: ref.watch(tacticalControllerProvider), - onAction: onAction, - onStrokeComplete: onStrokeComplete, - cursor: ref.watch(tacticalPointerPreviewProvider).point, - onNudgeCursor: onNudgeCursor, - onCommitCursor: onCommitCursor, - onFinishKeyboardPencil: onFinishKeyboardPencil, - onConfigurePencil: onConfigurePencil, - onConfigureMarker: onConfigureMarker, - onCommitMarkerName: onCommitMarkerName, - ); -} - -enum _TacticalAction { - addUnit, - openFleet, - closeFleet, - openUnitSetup, - closeUnitHangar, - clearSelection, - resetView, - selectTool, - pencilTool, - markerTool, - clearSceneObjects, - removeSelectedSceneObject, - cancel, -} - -class _NudgeSceneCursorIntent extends Intent { - const _NudgeSceneCursorIntent(this.dx, this.dy); - - final double dx; - final double dy; -} - -class _CommitSceneCursorIntent extends Intent { - const _CommitSceneCursorIntent(); -} - -Set _layerNames(Set overlays) => { - if (overlays.contains(TacticalMapOverlay.drones)) 'Drones', -}; - -TacticalMapOverlay _overlayForLabel(String label) => switch (label) { - 'Drones' => TacticalMapOverlay.drones, - _ => throw ArgumentError.value(label, 'label', 'Unknown tactical overlay'), -}; - -class _SatelliteMapBackground extends StatelessWidget { - const _SatelliteMapBackground(); - - @override - Widget build(BuildContext context) => ExcludeSemantics( - child: Image.asset( - 'assets/test.png', - fit: BoxFit.cover, - cacheWidth: 1920, - filterQuality: FilterQuality.medium, - errorBuilder: (context, error, stackTrace) => const ColoredBox( - color: tacticalBlack, - child: Center( - child: _UnavailableVisual( - icon: Icons.public_off_outlined, - label: 'DEMO MAP VISUAL UNAVAILABLE', - ), - ), - ), - ), - ); -} - -class _MapAtmosphere extends StatelessWidget { - const _MapAtmosphere(); - - @override - Widget build(BuildContext context) => IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - tacticalBlack.withValues(alpha: .36), - tacticalBlack.withValues(alpha: .72), - ], - ), - ), - child: const CustomPaint(painter: _MapGridPainter()), - ), - ); -} - -class _DemoDisclosureBanner extends StatelessWidget { - const _DemoDisclosureBanner(); - - @override - Widget build(BuildContext context) => Semantics( - container: true, - label: 'Local scene data. No vehicle link. Command authority unavailable.', - child: _TacticalGlass( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - borderRadius: BorderRadius.circular(12), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.science_outlined, color: accentOrange, size: 16), - const SizedBox(width: 8), - Flexible( - child: _HudLabel( - 'LOCAL SCENE DATA · NO VEHICLE LINK · COMMAND AUTHORITY UNAVAILABLE', - color: accentOrange, - ), - ), - ], - ), - ), - ); -} - -class _UnavailableVisual extends StatelessWidget { - const _UnavailableVisual({required this.icon, required this.label}); - - final IconData icon; - final String label; - - @override - Widget build(BuildContext context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: textSecondary), - const SizedBox(height: 8), - _HudLabel(label, color: textSecondary), - ], - ); -} - -class _DemoMapOverlays extends ConsumerWidget { - const _DemoMapOverlays({ - required this.layers, - required this.drawings, - required this.markers, - required this.draftPencil, - required this.draftMarker, - required this.localUnits, - required this.selectedUnitIds, - required this.onUnitSelected, - required this.onSelectionRect, - required this.onMarkerSelected, - }); - - final Set layers; - final List drawings; - final List markers; - final TacticalPencilMode? draftPencil; - final TacticalMarkerMode? draftMarker; - final List localUnits; - final Set selectedUnitIds; - final ValueChanged onUnitSelected; - final void Function(LocalScenePoint start, LocalScenePoint end) - onSelectionRect; - final ValueChanged onMarkerSelected; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final pointerPreview = ref.watch(tacticalPointerPreviewProvider); - return Stack( - children: [ - Positioned.fill( - child: IgnorePointer( - child: ExcludeSemantics( - child: RepaintBoundary( - child: CustomPaint( - key: const Key('tactical-map-overlay-painter'), - painter: _DemoMapOverlayPainter( - drawings: drawings, - markers: markers, - draftPencil: draftPencil, - draftMarker: draftMarker, - pointerPreview: pointerPreview.point, - previewStroke: pointerPreview.stroke, - ), - ), - ), - ), - ), - ), - Positioned.fill(child: _MapSelectionInput(onComplete: onSelectionRect)), - if (layers.contains('Drones')) - for (final unit in _fleet) - Align( - alignment: _unitMapAnchors[unit.id]!, - child: _DemoUnitMapMarker( - unit: unit, - selected: selectedUnitIds.contains(unit.id), - onTap: () => onUnitSelected(unit.id), - ), - ), - for (final unit in localUnits) - Align( - alignment: Alignment( - unit.localPlacement.x * 2 - 1, - unit.localPlacement.y * 2 - 1, - ), - child: _LocalUnitMapMarker( - unit: unit, - selected: selectedUnitIds.contains(unit.localUnitId), - onTap: () => onUnitSelected(unit.localUnitId), - ), - ), - for (final marker in markers) - Align( - alignment: Alignment( - marker.point.x * 2 - 1, - marker.point.y * 2 - 1, - ), - child: _LocalSceneMarkerPin( - marker: marker, - onTap: () => onMarkerSelected(marker.id), - ), - ), - ], - ); - } -} - -class _LocalSceneMarkerPin extends StatelessWidget { - const _LocalSceneMarkerPin({required this.marker, required this.onTap}); - - final LocalSceneMarker marker; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) => Semantics( - button: true, - label: 'Local marker ${marker.name}, session-only, not a vehicle location', - child: TextButton.icon( - key: Key('map-marker-${marker.id}'), - onPressed: onTap, - style: TextButton.styleFrom( - foregroundColor: _markerColor( - marker.hue, - marker.saturation, - marker.brightness, - ), - backgroundColor: panelBackground.withValues(alpha: .9), - side: BorderSide( - color: _markerColor(marker.hue, marker.saturation, marker.brightness), - ), - shape: const StadiumBorder(), - ), - icon: const Icon(Icons.location_on_outlined, size: 14), - label: Text( - marker.name, - style: GoogleFonts.inter(fontSize: 8, fontWeight: FontWeight.w800), - ), - ), - ); -} - -/// Pointer-only RTS box selection. Keyboard users have equivalent selection -/// through the roster and focusable map markers. Motion is coalesced to one -/// paint per frame and never enters the tactical state reducer until release. -class _MapSelectionInput extends StatefulWidget { - const _MapSelectionInput({required this.onComplete}); - - final void Function(LocalScenePoint start, LocalScenePoint end) onComplete; - - @override - State<_MapSelectionInput> createState() => _MapSelectionInputState(); -} - -class _MapSelectionInputState extends State<_MapSelectionInput> { - LocalScenePoint? _start; - LocalScenePoint? _current; - LocalScenePoint? _pending; - bool _frameScheduled = false; - - @override - Widget build(BuildContext context) => Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (event) { - if (event.buttons != kPrimaryButton) { - return; - } - final point = _pointFor(event.position); - if (point == null) { - return; - } - setState(() { - _start = point; - _current = point; - }); - }, - onPointerMove: (event) { - if (_start == null) { - return; - } - _pending = _pointFor(event.position); - if (_frameScheduled || _pending == null) { - return; - } - _frameScheduled = true; - SchedulerBinding.instance.scheduleFrameCallback((_) { - _frameScheduled = false; - final point = _pending; - _pending = null; - if (!mounted || point == null || _start == null) { - return; - } - setState(() => _current = point); - }); - }, - onPointerUp: (event) { - final start = _start; - final end = _pointFor(event.position); - setState(() { - _start = null; - _current = null; - }); - if (start == null || end == null) { - return; - } - widget.onComplete(start, end); - }, - onPointerCancel: (_) => setState(() { - _start = null; - _current = null; - _pending = null; - }), - child: ExcludeSemantics( - child: RepaintBoundary( - child: CustomPaint( - painter: _SelectionBoxPainter(start: _start, current: _current), - child: const SizedBox.expand(), - ), - ), - ), - ); - - LocalScenePoint? _pointFor(Offset globalPosition) { - final box = context.findRenderObject() as RenderBox?; - if (box == null || box.size.isEmpty) { - return null; - } - final local = box.globalToLocal(globalPosition); - return LocalScenePoint( - x: (local.dx / box.size.width).clamp(0.0, 1.0), - y: (local.dy / box.size.height).clamp(0.0, 1.0), - ); - } -} - -class _SelectionBoxPainter extends CustomPainter { - const _SelectionBoxPainter({required this.start, required this.current}); - - final LocalScenePoint? start; - final LocalScenePoint? current; - - @override - void paint(Canvas canvas, Size size) { - final origin = start; - final end = current; - if (origin == null || end == null) { - return; - } - final rect = Rect.fromPoints( - Offset(origin.x * size.width, origin.y * size.height), - Offset(end.x * size.width, end.y * size.height), - ); - canvas.drawRect(rect, Paint()..color = accentMint.withValues(alpha: .1)); - canvas.drawRect( - rect, - Paint() - ..color = accentMint - ..style = PaintingStyle.stroke - ..strokeWidth = 1.5, - ); - } - - @override - bool shouldRepaint(covariant _SelectionBoxPainter oldDelegate) => - oldDelegate.start != start || oldDelegate.current != current; -} - -/// A semantic hit target over the decorative local-scene marker. The marker -/// selects only mock scene data; it never binds or commands a vehicle. -class _DemoUnitMapMarker extends StatelessWidget { - const _DemoUnitMapMarker({ - required this.unit, - required this.selected, - required this.onTap, - }); - - final _DroneData unit; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) => Semantics( - button: true, - selected: selected, - label: '${unit.id}, simulated local scene unit, not a vehicle position', - child: TextButton.icon( - key: Key('map-unit-${unit.id}'), - onPressed: onTap, - style: TextButton.styleFrom( - foregroundColor: selected ? tacticalBlack : accentMint, - backgroundColor: selected - ? accentMint - : panelBackground.withValues(alpha: .88), - side: BorderSide(color: selected ? accentMint : panelBorder), - shape: const StadiumBorder(), - ), - icon: const Icon(Icons.flight_outlined, size: 14), - label: Text( - unit.id, - style: GoogleFonts.robotoMono(fontSize: 8, fontWeight: FontWeight.w700), - ), - ), - ); -} - -class _LocalUnitMapMarker extends StatelessWidget { - const _LocalUnitMapMarker({ - required this.unit, - required this.selected, - required this.onTap, - }); - - final FleetUnit unit; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) => Semantics( - button: true, - selected: selected, - label: - '${unit.displayName}, local workspace unit, local layout only, no vehicle position reported', - child: TextButton.icon( - onPressed: onTap, - style: TextButton.styleFrom( - foregroundColor: selected ? tacticalBlack : accentMint, - backgroundColor: selected - ? accentMint - : panelBackground.withValues(alpha: .9), - side: BorderSide(color: selected ? accentMint : panelBorder), - shape: const StadiumBorder(), - ), - icon: const Icon(Icons.flight_outlined, size: 15), - label: Text( - unit.displayName, - style: GoogleFonts.robotoMono(fontSize: 8, fontWeight: FontWeight.w700), - ), - ), - ); -} - -class _TacticalInspector extends StatelessWidget { - const _TacticalInspector({ - required this.selectedUnitId, - required this.selectedDemo, - required this.workspaceUnits, - }); - - final String selectedUnitId; - final _DroneData? selectedDemo; - final List workspaceUnits; - - @override - Widget build(BuildContext context) { - final local = workspaceUnits.cast().firstWhere( - (unit) => unit?.localUnitId == selectedUnitId, - orElse: () => null, - ); - final isLocal = local != null; - final name = local?.displayName ?? selectedDemo?.id ?? selectedUnitId; - return Semantics( - container: true, - label: '$name tactical inspector', - child: _TacticalGlass( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const _HudLabel('SELECTION INSPECTOR'), - const SizedBox(height: 6), - _MonoText( - name, - color: textPrimary, - size: 12, - weight: FontWeight.w700, - ), - const SizedBox(height: 8), - _InspectorFact( - label: 'LOCAL CONTEXT', - value: isLocal - ? 'UNBOUND WORKSPACE UNIT' - : 'SIMULATED LOCAL DATA', - ), - const _InspectorFact( - label: 'REPORTED PLATFORM', - value: 'NOT REPORTED', - ), - const _InspectorFact( - label: 'COMPATIBILITY', - value: 'NOT EVALUATED', - ), - const _InspectorFact( - label: 'WRITE AUTHORITY', - value: 'UNAVAILABLE', - ), - ], - ), - ), - ); - } -} - -class _InspectorFact extends StatelessWidget { - const _InspectorFact({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(top: 6), - child: Row( - children: [ - Expanded(child: _HudLabel(label)), - Flexible(child: _HudLabel(value, color: textPrimary)), - ], - ), - ); -} - -class _DrawingInspector extends StatelessWidget { - const _DrawingInspector({required this.drawing, required this.onDelete}); - - final LocalSceneDrawing drawing; - final VoidCallback onDelete; - - @override - Widget build(BuildContext context) => _TacticalGlass( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const _HudLabel('SELECTION INSPECTOR'), - const SizedBox(height: 6), - _MonoText( - 'LOCAL PENCIL DRAWING', - color: _pencilColor( - drawing.hue, - drawing.saturation, - drawing.brightness, - ), - size: 12, - weight: FontWeight.w700, - ), - const SizedBox(height: 8), - const _HudLabel( - 'LOCAL SCENE ANNOTATION · NOT A VEHICLE LOCATION', - color: accentOrange, - ), - const SizedBox(height: 4), - Text( - '${drawing.points.length} stroke point(s) · ${drawing.thickness.toStringAsFixed(0)} px · session only', - ), - const SizedBox(height: 10), - _UnitActionButton( - label: 'REMOVE LOCAL OVERLAY', - icon: Icons.delete_outline, - foreground: textPrimary, - background: tacticalTransparent, - onPressed: onDelete, - ), - ], - ), - ); -} - -class _MarkerInspector extends StatelessWidget { - const _MarkerInspector({required this.marker, required this.onDelete}); - - final LocalSceneMarker marker; - final VoidCallback onDelete; - - @override - Widget build(BuildContext context) => _TacticalGlass( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const _HudLabel('SELECTION INSPECTOR'), - const SizedBox(height: 6), - _MonoText( - marker.name, - color: _markerColor(marker.hue, marker.saturation, marker.brightness), - size: 12, - weight: FontWeight.w700, - ), - const SizedBox(height: 8), - const _HudLabel( - 'LOCAL MARKER · NOT A VEHICLE LOCATION', - color: accentOrange, - ), - const SizedBox(height: 4), - const Text('Session only · not sent to a vehicle'), - const SizedBox(height: 10), - _UnitActionButton( - label: 'REMOVE LOCAL MARKER', - icon: Icons.delete_outline, - foreground: textPrimary, - background: tacticalTransparent, - onPressed: onDelete, - ), - ], - ), - ); -} - -class _FleetWorkspaceRoster extends StatelessWidget { - const _FleetWorkspaceRoster({ - required this.workspaceUnits, - required this.tactical, - required this.onSelected, - required this.onClose, - }); - - final List workspaceUnits; - final TacticalSceneRenderState tactical; - final ValueChanged onSelected; - final VoidCallback onClose; - - @override - Widget build(BuildContext context) => _TacticalGlass( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Expanded(child: _HudLabel('LOCAL FLEET WORKSPACE')), - IconButton( - tooltip: 'Close local fleet workspace', - onPressed: onClose, - icon: const Icon(Icons.close, size: 18), - ), - ], - ), - const SizedBox(height: 4), - const Text('Local units only. No vehicle link or authority.'), - const SizedBox(height: 10), - Expanded( - child: ListView.separated( - itemCount: workspaceUnits.length, - separatorBuilder: (context, index) => const SizedBox(height: 6), - itemBuilder: (context, index) { - final unit = workspaceUnits[index]; - final selected = tactical.selectedUnitIds.contains( - unit.localUnitId, - ); - return Semantics( - button: true, - selected: selected, - label: '${unit.displayName}, local workspace unit', - child: OutlinedButton( - onPressed: () => onSelected(unit.localUnitId), - style: OutlinedButton.styleFrom( - alignment: Alignment.centerLeft, - foregroundColor: selected ? accentMint : textPrimary, - side: BorderSide( - color: selected ? accentMint : panelBorder, - ), - ), - child: Text(unit.displayName), - ), - ); - }, - ), - ), - ], - ), - ); -} - -class _DesktopCommandDeck extends StatelessWidget { - const _DesktopCommandDeck({ - required this.width, - required this.height, - required this.layers, - required this.onLayerChanged, - required this.selectedUnit, - required this.selectedUnitIds, - required this.onUnitSelected, - required this.tactical, - required this.workspaceUnits, - required this.onAction, - required this.sourceLabel, - required this.sourceStatus, - required this.onOpenEventStream, - required this.unitsTriggerFocus, - required this.onCloseHangar, - }); - - final double width; - final double height; - final Set layers; - final void Function(String layer, bool enabled) onLayerChanged; - final _DroneData? selectedUnit; - final Set selectedUnitIds; - final ValueChanged onUnitSelected; - final TacticalSceneRenderState tactical; - final List workspaceUnits; - final ValueChanged<_TacticalAction> onAction; - final String sourceLabel; - final String sourceStatus; - final VoidCallback onOpenEventStream; - final FocusNode unitsTriggerFocus; - final VoidCallback onCloseHangar; - - @override - Widget build(BuildContext context) { - final fpvWidth = math.min(560.0, width * .38); - final fpvHeight = math.min(474.0, height * .52); - const unitTrayHeight = 164.0; - final hangarOpen = - tactical.panel == TacticalPanel.unitHangar || - tactical.panel == TacticalPanel.unitSetup; - // Setup adds an editable local-name form and the capability-gated - // configuration explanation, so it grows without turning the horizontal - // category hangar itself into a tall modal. - final hangarHeight = tactical.panel == TacticalPanel.unitSetup - ? 350.0 - : 252.0; - final mapControlBottom = hangarOpen - ? hangarHeight + 32 - : unitTrayHeight + 32; - - return Stack( - children: [ - Positioned( - left: 20, - top: 16, - right: 20, - child: _TopNavigation( - sourceLabel: sourceLabel, - sourceStatus: sourceStatus, - onOpenEventStream: onOpenEventStream, - ), - ), - const Positioned( - left: 20, - top: 90, - right: 20, - child: _DemoDisclosureBanner(), - ), - if (selectedUnit != null) - Positioned( - right: 24, - top: 136, - width: fpvWidth, - height: fpvHeight, - child: _FpvPanel(selectedUnit: selectedUnit!), - ), - if (selectedUnitIds.length == 1) - Positioned( - right: 24, - top: selectedUnit == null ? 136 : 136 + fpvHeight + 12, - width: fpvWidth, - child: _TacticalInspector( - selectedUnitId: selectedUnitIds.single, - selectedDemo: selectedUnit, - workspaceUnits: workspaceUnits, - ), - ), - if (selectedUnitIds.isEmpty && tactical.selectedDrawingId != null) - Positioned( - right: 24, - top: 136, - width: fpvWidth, - child: _DrawingInspector( - drawing: tactical.drawings.firstWhere( - (drawing) => drawing.id == tactical.selectedDrawingId, - ), - onDelete: () => - onAction(_TacticalAction.removeSelectedSceneObject), - ), - ), - if (selectedUnitIds.isEmpty && tactical.selectedMarkerId != null) - Positioned( - right: 24, - top: 136, - width: fpvWidth, - child: _MarkerInspector( - marker: tactical.markers.firstWhere( - (marker) => marker.id == tactical.selectedMarkerId, - ), - onDelete: () => - onAction(_TacticalAction.removeSelectedSceneObject), - ), - ), - if (tactical.panel == TacticalPanel.fleetRoster) - Positioned( - left: 24, - top: 136, - width: math.min(380, width * .3), - bottom: mapControlBottom, - child: _FleetWorkspaceRoster( - workspaceUnits: workspaceUnits, - tactical: tactical, - onSelected: onUnitSelected, - onClose: () => onAction(_TacticalAction.closeFleet), - ), - ), - Positioned( - left: 24, - bottom: mapControlBottom, - child: _MapEdgeCommandControls( - tactical: tactical, - layers: layers, - onLayerChanged: onLayerChanged, - onAction: onAction, - unitsTriggerFocus: unitsTriggerFocus, - ), - ), - if (hangarOpen) - Positioned( - left: 24, - right: 24, - bottom: 24, - height: hangarHeight, - child: _UnitHangarDock(onClose: onCloseHangar), - ) - else - Positioned( - right: 24, - bottom: 24, - width: math.min(680, width * .48), - height: unitTrayHeight, - child: _UnitCommandTray( - selectedUnit: selectedUnit, - selectedUnitIds: selectedUnitIds, - onUnitSelected: onUnitSelected, - onAddUnit: () => onAction(_TacticalAction.addUnit), - onOpenSetup: () => onAction(_TacticalAction.openUnitSetup), - workspaceUnits: workspaceUnits, - ), - ), - ], - ); - } -} - -class _CompactCommandDeck extends StatelessWidget { - const _CompactCommandDeck({ - required this.layers, - required this.onLayerChanged, - required this.selectedUnit, - required this.selectedUnitIds, - required this.onUnitSelected, - required this.tactical, - required this.workspaceUnits, - required this.onAction, - required this.sourceLabel, - required this.sourceStatus, - required this.onOpenEventStream, - required this.unitsTriggerFocus, - required this.onCloseHangar, - }); - - final Set layers; - final void Function(String layer, bool enabled) onLayerChanged; - final _DroneData? selectedUnit; - final Set selectedUnitIds; - final ValueChanged onUnitSelected; - final TacticalSceneRenderState tactical; - final List workspaceUnits; - final ValueChanged<_TacticalAction> onAction; - final String sourceLabel; - final String sourceStatus; - final VoidCallback onOpenEventStream; - final FocusNode unitsTriggerFocus; - final VoidCallback onCloseHangar; - - @override - Widget build(BuildContext context) => SafeArea( - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - _TopNavigation( - compact: true, - sourceLabel: sourceLabel, - sourceStatus: sourceStatus, - onOpenEventStream: onOpenEventStream, - ), - const SizedBox(height: 16), - const _DemoDisclosureBanner(), - const SizedBox(height: 16), - if (selectedUnit != null) - SizedBox(height: 350, child: _FpvPanel(selectedUnit: selectedUnit!)), - const SizedBox(height: 16), - _CompactTacticalActions( - tactical: tactical, - layers: layers, - onLayerChanged: onLayerChanged, - onAction: onAction, - unitsTriggerFocus: unitsTriggerFocus, - ), - const SizedBox(height: 16), - _UnitCommandTray( - selectedUnit: selectedUnit, - selectedUnitIds: selectedUnitIds, - onUnitSelected: onUnitSelected, - onAddUnit: () => onAction(_TacticalAction.addUnit), - onOpenSetup: () => onAction(_TacticalAction.openUnitSetup), - workspaceUnits: workspaceUnits, - ), - if (tactical.panel == TacticalPanel.unitHangar || - tactical.panel == TacticalPanel.unitSetup) ...[ - const SizedBox(height: 16), - SizedBox( - height: 338, - child: _UnitHangarDock(compact: true, onClose: onCloseHangar), - ), - ], - ], - ), - ); -} - -/// RTS action taxonomy: Units changes only the local workspace; Cursor -/// selects or creates session-local scene data; View changes rendering. -/// None of these actions sends a vehicle command or consumes live telemetry. -class _MapEdgeCommandControls extends StatelessWidget { - const _MapEdgeCommandControls({ - required this.tactical, - required this.layers, - required this.onLayerChanged, - required this.onAction, - required this.unitsTriggerFocus, - }); - - final TacticalSceneRenderState tactical; - final Set layers; - final void Function(String layer, bool enabled) onLayerChanged; - final ValueChanged<_TacticalAction> onAction; - final FocusNode unitsTriggerFocus; - - @override - Widget build(BuildContext context) => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _TacticalMenuAnchor( - triggerKey: const Key('units-menu'), - focusNode: unitsTriggerFocus, - label: 'Units', - icon: Icons.groups_2_outlined, - items: [ - _MenuAction( - label: 'Add local unit', - detail: 'Creates local workspace context only', - icon: Icons.add_circle_outline, - onSelected: () => onAction(_TacticalAction.addUnit), - ), - _MenuAction( - label: 'Fleet workspace', - detail: 'Local unit roster is in the selection console', - icon: Icons.view_list_outlined, - onSelected: () => onAction(_TacticalAction.openFleet), - ), - const _MenuAction( - label: 'Live fleet', - detail: 'Requires verified source-to-unit binding', - icon: Icons.radar_outlined, - enabled: false, - ), - ], - ), - const SizedBox(height: 8), - _TacticalMenuAnchor( - triggerKey: const Key('cursor-menu'), - label: 'Mode: ${_modeLabel(tactical.mode)}', - icon: switch (tactical.mode) { - TacticalPencilMode() => Icons.draw_outlined, - TacticalMarkerMode() => Icons.location_on_outlined, - _ => Icons.ads_click_outlined, - }, - items: [ - _MenuAction( - label: 'Selection cursor', - detail: 'Default · click or draw a rectangle to select units', - icon: Icons.ads_click_outlined, - onSelected: () => onAction(_TacticalAction.selectTool), - ), - _MenuAction( - label: 'Pencil cursor', - detail: - 'Draw session-local marks with color and thickness controls', - icon: Icons.draw_outlined, - onSelected: () => onAction(_TacticalAction.pencilTool), - ), - _MenuAction( - label: 'Marker cursor', - detail: 'Click a local point, then give the marker a name', - icon: Icons.location_on_outlined, - onSelected: () => onAction(_TacticalAction.markerTool), - ), - ], - ), - const SizedBox(height: 8), - _TacticalMenuAnchor( - triggerKey: const Key('view-menu'), - label: 'View', - icon: Icons.layers_outlined, - items: [ - for (final layer in ['Drones']) - _MenuAction( - label: '${layers.contains(layer) ? 'Hide' : 'Show'} $layer', - detail: 'Changes the local tactical scene', - icon: layers.contains(layer) - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - onSelected: () => onLayerChanged(layer, !layers.contains(layer)), - ), - _MenuAction( - label: 'Reset local view', - detail: 'Returns the local scene to its default view', - icon: Icons.center_focus_strong_outlined, - onSelected: () => onAction(_TacticalAction.resetView), - ), - _MenuAction( - label: 'Cancel selection', - detail: 'Clears the current local selection', - icon: Icons.close_outlined, - onSelected: () => onAction(_TacticalAction.clearSelection), - ), - _MenuAction( - label: 'Clear local scene', - detail: tactical.drawings.isEmpty && tactical.markers.isEmpty - ? 'No local drawings or markers' - : 'Removes session-local pencil drawings and markers', - icon: Icons.layers_clear_outlined, - enabled: - tactical.drawings.isNotEmpty || tactical.markers.isNotEmpty, - onSelected: () => onAction(_TacticalAction.clearSceneObjects), - ), - ], - ), - ], - ); -} - -class _TacticalMenuAnchor extends StatelessWidget { - const _TacticalMenuAnchor({ - required this.triggerKey, - required this.label, - required this.icon, - required this.items, - this.focusNode, - }); - - final Key triggerKey; - final String label; - final IconData icon; - final List<_MenuAction> items; - final FocusNode? focusNode; - - @override - Widget build(BuildContext context) => MenuAnchor( - alignmentOffset: const Offset(0, -4), - menuChildren: [ - for (final item in items) - MenuItemButton( - onPressed: item.enabled ? item.onSelected : null, - leadingIcon: Icon(item.icon, size: 18), - child: SizedBox( - width: 245, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(item.label), - const SizedBox(height: 3), - Text( - item.detail, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: textSecondary), - ), - ], - ), - ), - ), - ], - builder: (context, controller, child) => _MapEdgeTrigger( - key: triggerKey, - label: label, - icon: icon, - focusNode: focusNode, - onPressed: () => - controller.isOpen ? controller.close() : controller.open(), - ), - ); -} - -class _MenuAction { - const _MenuAction({ - required this.label, - required this.detail, - required this.icon, - this.enabled = true, - this.onSelected, - }); - - final String label; - final String detail; - final IconData icon; - final bool enabled; - final VoidCallback? onSelected; -} - -class _MapEdgeTrigger extends StatelessWidget { - const _MapEdgeTrigger({ - super.key, - required this.label, - required this.icon, - required this.onPressed, - this.focusNode, - }); - - final String label; - final IconData icon; - final VoidCallback onPressed; - final FocusNode? focusNode; - - @override - Widget build(BuildContext context) => Semantics( - button: true, - label: label, - child: Tooltip( - message: label, - child: SizedBox( - width: 48, - height: 48, - child: _TacticalGlass( - borderRadius: BorderRadius.circular(12), - child: TextButton( - focusNode: focusNode, - onPressed: onPressed, - style: TextButton.styleFrom( - foregroundColor: accentMint, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(11), - ), - ), - child: Icon(icon, size: 21), - ), - ), - ), - ), - ); -} - -class _CompactTacticalActions extends StatelessWidget { - const _CompactTacticalActions({ - required this.tactical, - required this.layers, - required this.onLayerChanged, - required this.onAction, - required this.unitsTriggerFocus, - }); - - final TacticalSceneRenderState tactical; - final Set layers; - final void Function(String layer, bool enabled) onLayerChanged; - final ValueChanged<_TacticalAction> onAction; - final FocusNode unitsTriggerFocus; - - @override - Widget build(BuildContext context) => Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _CompactActionButton( - label: 'Units', - icon: Icons.groups_2_outlined, - focusNode: unitsTriggerFocus, - onPressed: () => _showActionSheet( - context, - title: 'Units', - actions: [ - _MenuAction( - label: 'Add local unit', - detail: 'Creates local workspace context only', - icon: Icons.add_circle_outline, - onSelected: () => onAction(_TacticalAction.addUnit), - ), - _MenuAction( - label: 'Fleet workspace', - detail: 'Open the local unit roster', - icon: Icons.view_list_outlined, - onSelected: () => onAction(_TacticalAction.openFleet), - ), - const _MenuAction( - label: 'Live fleet', - detail: 'Requires verified source-to-unit binding', - icon: Icons.radar_outlined, - enabled: false, - ), - ], - ), - ), - _CompactActionButton( - label: 'Mode: ${_modeLabel(tactical.mode)}', - icon: switch (tactical.mode) { - TacticalPencilMode() => Icons.draw_outlined, - TacticalMarkerMode() => Icons.location_on_outlined, - _ => Icons.ads_click_outlined, - }, - onPressed: () => _showActionSheet( - context, - title: 'Cursor', - actions: [ - _MenuAction( - label: 'Selection cursor', - detail: 'Default · click or draw a rectangle to select units', - icon: Icons.ads_click_outlined, - onSelected: () => onAction(_TacticalAction.selectTool), - ), - _MenuAction( - label: 'Pencil cursor', - detail: - 'Draw session-local marks with color and thickness controls', - icon: Icons.draw_outlined, - onSelected: () => onAction(_TacticalAction.pencilTool), - ), - _MenuAction( - label: 'Marker cursor', - detail: 'Click a local point, then give the marker a name', - icon: Icons.location_on_outlined, - onSelected: () => onAction(_TacticalAction.markerTool), - ), - ], - ), - ), - _CompactActionButton( - label: 'View', - icon: Icons.layers_outlined, - onPressed: () => - _showViewSheet(context, tactical, layers, onLayerChanged, onAction), - ), - ], - ); -} - -class _CompactActionButton extends StatelessWidget { - const _CompactActionButton({ - required this.label, - required this.icon, - required this.onPressed, - this.focusNode, - }); - - final String label; - final IconData icon; - final VoidCallback onPressed; - final FocusNode? focusNode; - - @override - Widget build(BuildContext context) => Semantics( - button: true, - label: label, - child: Tooltip( - message: label, - child: SizedBox( - width: 48, - height: 48, - child: _TacticalGlass( - borderRadius: BorderRadius.circular(12), - child: TextButton( - focusNode: focusNode, - onPressed: onPressed, - style: TextButton.styleFrom( - foregroundColor: accentMint, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(11), - ), - ), - child: Icon(icon, size: 21), - ), - ), - ), - ), - ); -} - -Future _showActionSheet( - BuildContext context, { - required String title, - required List<_MenuAction> actions, -}) => showModalBottomSheet( - context: context, - backgroundColor: tacticalBlack, - isScrollControlled: true, - builder: (context) => SafeArea( - child: SizedBox( - height: MediaQuery.sizeOf(context).height * .82, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - ], - ), - const SizedBox(height: 12), - Expanded( - child: ListView( - key: const Key('tactical-action-sheet-scroll'), - children: [ - for (final action in actions) - ListTile( - enabled: action.enabled, - leading: Icon(action.icon), - title: Text(action.label), - subtitle: Text(action.detail), - onTap: action.enabled - ? () { - Navigator.pop(context); - action.onSelected?.call(); - } - : null, - ), - ], - ), - ), - ], - ), - ), - ), - ), -); - -Future _showViewSheet( - BuildContext context, - TacticalSceneRenderState tactical, - Set layers, - void Function(String layer, bool enabled) onLayerChanged, - ValueChanged<_TacticalAction> onAction, -) => _showActionSheet( - context, - title: 'View', - actions: [ - for (final label in ['Drones']) - _MenuAction( - label: '${layers.contains(label) ? 'Hide' : 'Show'} $label', - detail: 'Changes the local tactical scene', - icon: layers.contains(label) - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - onSelected: () => onLayerChanged(label, !layers.contains(label)), - ), - _MenuAction( - label: 'Reset local view', - detail: 'Returns the local scene to its default view', - icon: Icons.center_focus_strong_outlined, - onSelected: () => onAction(_TacticalAction.resetView), - ), - _MenuAction( - label: 'Cancel selection', - detail: 'Clears the current local selection', - icon: Icons.close_outlined, - onSelected: () => onAction(_TacticalAction.clearSelection), - ), - _MenuAction( - label: 'Clear local scene', - detail: tactical.drawings.isEmpty && tactical.markers.isEmpty - ? 'No local drawings or markers' - : 'Removes session-local pencil drawings and markers', - icon: Icons.layers_clear_outlined, - enabled: tactical.drawings.isNotEmpty || tactical.markers.isNotEmpty, - onSelected: () => onAction(_TacticalAction.clearSceneObjects), - ), - ], -); - -class _TacticalEditOverlay extends StatelessWidget { - const _TacticalEditOverlay({ - required this.tactical, - required this.onAction, - required this.onStrokeComplete, - required this.cursor, - required this.onNudgeCursor, - required this.onCommitCursor, - required this.onFinishKeyboardPencil, - required this.onConfigurePencil, - required this.onConfigureMarker, - required this.onCommitMarkerName, - }); - - final TacticalUiState tactical; - final ValueChanged<_TacticalAction> onAction; - final ValueChanged> onStrokeComplete; - final LocalScenePoint? cursor; - final void Function(double dx, double dy) onNudgeCursor; - final VoidCallback onCommitCursor; - final VoidCallback onFinishKeyboardPencil; - final void Function({ - double? hue, - double? saturation, - double? brightness, - double? thickness, - }) - onConfigurePencil; - final void Function({double? hue, double? saturation, double? brightness}) - onConfigureMarker; - final ValueChanged onCommitMarkerName; - - @override - Widget build(BuildContext context) { - final mode = tactical.mode; - if (mode is TacticalPencilMode) { - return _PencilToolOverlay( - mode: mode, - cursor: cursor, - onStrokeComplete: onStrokeComplete, - onCancel: () => onAction(_TacticalAction.cancel), - onNudgeCursor: onNudgeCursor, - onCommitCursor: onCommitCursor, - onFinishKeyboardPencil: onFinishKeyboardPencil, - onConfigure: onConfigurePencil, - ); - } - if (mode is TacticalMarkerMode) { - return _MarkerToolOverlay( - mode: mode, - cursor: cursor, - onCancel: () => onAction(_TacticalAction.cancel), - onNudgeCursor: onNudgeCursor, - onCommitCursor: onCommitCursor, - onConfigure: onConfigureMarker, - onSaveName: onCommitMarkerName, - onSelectTool: () => onAction(_TacticalAction.selectTool), - ); - } - return const SizedBox.shrink(); - } -} - -class _PencilToolOverlay extends StatelessWidget { - const _PencilToolOverlay({ - required this.mode, - required this.cursor, - required this.onStrokeComplete, - required this.onCancel, - required this.onNudgeCursor, - required this.onCommitCursor, - required this.onFinishKeyboardPencil, - required this.onConfigure, - }); - - final TacticalPencilMode mode; - final LocalScenePoint? cursor; - final ValueChanged> onStrokeComplete; - final VoidCallback onCancel; - final void Function(double dx, double dy) onNudgeCursor; - final VoidCallback onCommitCursor; - final VoidCallback onFinishKeyboardPencil; - final void Function({ - double? hue, - double? saturation, - double? brightness, - double? thickness, - }) - onConfigure; - - @override - Widget build(BuildContext context) => Stack( - children: [ - Positioned.fill( - child: _TacticalPointerInput( - mode: mode, - onPoint: (_) {}, - onStrokeComplete: onStrokeComplete, - onCancel: onCancel, - ), - ), - Positioned( - left: 24, - right: 24, - top: 116, - child: FocusTraversalGroup( - child: FocusScope( - autofocus: true, - child: Shortcuts( - shortcuts: const { - SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft): - _NudgeSceneCursorIntent(-.04, 0), - SingleActivator(LogicalKeyboardKey.arrowRight): - _NudgeSceneCursorIntent(.04, 0), - SingleActivator(LogicalKeyboardKey.arrowUp): - _NudgeSceneCursorIntent(0, -.04), - SingleActivator(LogicalKeyboardKey.arrowDown): - _NudgeSceneCursorIntent(0, .04), - SingleActivator(LogicalKeyboardKey.enter): - _CommitSceneCursorIntent(), - }, - child: Actions( - actions: { - DismissIntent: CallbackAction( - onInvoke: (_) { - onCancel(); - return null; - }, - ), - _NudgeSceneCursorIntent: - CallbackAction<_NudgeSceneCursorIntent>( - onInvoke: (intent) { - onNudgeCursor(intent.dx, intent.dy); - return null; - }, - ), - _CommitSceneCursorIntent: - CallbackAction<_CommitSceneCursorIntent>( - onInvoke: (_) { - onCommitCursor(); - return null; - }, - ), - }, - child: Semantics( - container: true, - label: 'Pencil cursor. Session-only local drawing tool.', - child: _PencilToolbar( - mode: mode, - cursor: cursor ?? const LocalScenePoint(x: .5, y: .5), - onConfigure: onConfigure, - onAddPoint: onCommitCursor, - onFinish: onFinishKeyboardPencil, - onCancel: onCancel, - ), - ), - ), - ), - ), - ), - ), - ], - ); -} - -class _PencilToolbar extends StatelessWidget { - const _PencilToolbar({ - required this.mode, - required this.cursor, - required this.onConfigure, - required this.onAddPoint, - required this.onFinish, - required this.onCancel, - }); - - final TacticalPencilMode mode; - final LocalScenePoint cursor; - final void Function({ - double? hue, - double? saturation, - double? brightness, - double? thickness, - }) - onConfigure; - final VoidCallback onAddPoint; - final VoidCallback onFinish; - final VoidCallback onCancel; - - @override - Widget build(BuildContext context) => _TacticalGlass( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Wrap( - spacing: 14, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - const _HudLabel('PENCIL CURSOR', color: accentOrange), - _ColorSwatches( - toolLabel: 'Pencil', - keyPrefix: 'pencil', - hue: mode.hue, - onSelected: (hue) => onConfigure(hue: hue), - ), - SizedBox( - width: 160, - child: Semantics( - label: 'Pencil color hue', - child: Slider( - key: const Key('pencil-hue'), - value: mode.hue, - min: 0, - max: 360, - activeColor: _pencilColor( - mode.hue, - mode.saturation, - mode.brightness, - ), - onChanged: (hue) => onConfigure(hue: hue), - ), - ), - ), - SizedBox( - width: 130, - child: Semantics( - label: 'Pencil thickness', - child: Slider( - key: const Key('pencil-thickness'), - value: mode.thickness, - min: 1, - max: 12, - divisions: 11, - activeColor: _pencilColor( - mode.hue, - mode.saturation, - mode.brightness, - ), - onChanged: (thickness) => onConfigure(thickness: thickness), - ), - ), - ), - _HudLabel('${mode.thickness.toStringAsFixed(0)} PX'), - _StyleSlider( - key: const Key('pencil-saturation'), - label: 'Pencil saturation', - value: mode.saturation, - color: _pencilColor(mode.hue, 1, mode.brightness), - onChanged: (value) => onConfigure(saturation: value), - ), - _StyleSlider( - key: const Key('pencil-brightness'), - label: 'Pencil brightness', - value: mode.brightness, - color: _pencilColor(mode.hue, mode.saturation, 1), - onChanged: (value) => onConfigure(brightness: value), - ), - _KeyboardMapCursor( - cursor: cursor, - isDrawing: true, - canFinishDrawing: mode.keyboardPoints.length >= 2, - pointCount: mode.keyboardPoints.length, - onCommit: onAddPoint, - onFinish: onFinish, - ), - _UnitActionButton( - label: 'SELECTION CURSOR', - icon: Icons.ads_click_outlined, - foreground: textPrimary, - background: tacticalTransparent, - onPressed: onCancel, - ), - ], - ), - ); -} - -class _ColorSwatches extends StatelessWidget { - const _ColorSwatches({ - required this.toolLabel, - required this.keyPrefix, - required this.hue, - required this.onSelected, - this.colorForHue = _pencilColor, - }); - - final String toolLabel; - final String keyPrefix; - final double hue; - final ValueChanged onSelected; - final Color Function(double hue) colorForHue; - - @override - Widget build(BuildContext context) => Wrap( - spacing: 6, - children: [ - for (final swatchHue in [0.0, 26.0, 58.0, 164.0, 208.0, 282.0, 332.0]) - Semantics( - button: true, - selected: (hue - swatchHue).abs() < 2, - label: '$toolLabel color ${swatchHue.round()} degrees', - child: InkWell( - key: Key('$keyPrefix-swatch-${swatchHue.round()}'), - onTap: () => onSelected(swatchHue), - borderRadius: BorderRadius.circular(12), - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: colorForHue(swatchHue), - shape: BoxShape.circle, - border: Border.all( - color: (hue - swatchHue).abs() < 2 - ? textPrimary - : panelBorder, - width: 2, - ), - ), - ), - ), - ), - ], - ); -} - -Color _pencilColor(double hue, [double saturation = .78, double value = .94]) => - HSVColor.fromAHSV(1, hue, saturation, value).toColor(); - -class _MarkerToolOverlay extends StatelessWidget { - const _MarkerToolOverlay({ - required this.mode, - required this.cursor, - required this.onCancel, - required this.onNudgeCursor, - required this.onCommitCursor, - required this.onConfigure, - required this.onSaveName, - required this.onSelectTool, - }); - - final TacticalMarkerMode mode; - final LocalScenePoint? cursor; - final VoidCallback onCancel; - final void Function(double dx, double dy) onNudgeCursor; - final VoidCallback onCommitCursor; - final void Function({double? hue, double? saturation, double? brightness}) - onConfigure; - final ValueChanged onSaveName; - final VoidCallback onSelectTool; - - @override - Widget build(BuildContext context) => Stack( - children: [ - Positioned.fill( - child: _TacticalPointerInput( - mode: mode, - onPoint: (point) => ProviderScope.containerOf( - context, - ).read(tacticalControllerProvider.notifier).chooseMarkerPoint(point), - onStrokeComplete: (_) {}, - onCancel: onCancel, - ), - ), - Positioned( - left: 24, - right: 24, - top: 116, - child: FocusTraversalGroup( - child: FocusScope( - autofocus: true, - child: Shortcuts( - shortcuts: const { - SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft): - _NudgeSceneCursorIntent(-.04, 0), - SingleActivator(LogicalKeyboardKey.arrowRight): - _NudgeSceneCursorIntent(.04, 0), - SingleActivator(LogicalKeyboardKey.arrowUp): - _NudgeSceneCursorIntent(0, -.04), - SingleActivator(LogicalKeyboardKey.arrowDown): - _NudgeSceneCursorIntent(0, .04), - SingleActivator(LogicalKeyboardKey.enter): - _CommitSceneCursorIntent(), - }, - child: Actions( - actions: { - DismissIntent: CallbackAction( - onInvoke: (_) { - onCancel(); - return null; - }, - ), - _NudgeSceneCursorIntent: - CallbackAction<_NudgeSceneCursorIntent>( - onInvoke: (intent) { - onNudgeCursor(intent.dx, intent.dy); - return null; - }, - ), - _CommitSceneCursorIntent: - CallbackAction<_CommitSceneCursorIntent>( - onInvoke: (_) { - onCommitCursor(); - return null; - }, - ), - }, - child: Semantics( - container: true, - label: 'Marker cursor. Session-only local marker tool.', - child: _MarkerToolbar( - mode: mode, - cursor: cursor ?? const LocalScenePoint(x: .5, y: .5), - onConfigure: onConfigure, - onPlaceAtCursor: onCommitCursor, - onSelectTool: onSelectTool, - ), - ), - ), - ), - ), - ), - ), - if (mode.pendingPoint != null) - Positioned( - left: 24, - top: 220, - width: math.min(360, MediaQuery.sizeOf(context).width - 48), - child: _MarkerNamePrompt(onSave: onSaveName, onCancel: onCancel), - ), - ], - ); -} - -class _MarkerToolbar extends StatelessWidget { - const _MarkerToolbar({ - required this.mode, - required this.cursor, - required this.onConfigure, - required this.onPlaceAtCursor, - required this.onSelectTool, - }); - - final TacticalMarkerMode mode; - final LocalScenePoint cursor; - final void Function({double? hue, double? saturation, double? brightness}) - onConfigure; - final VoidCallback onPlaceAtCursor; - final VoidCallback onSelectTool; - - @override - Widget build(BuildContext context) => _TacticalGlass( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Wrap( - spacing: 14, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - const _HudLabel('MARKER CURSOR', color: accentOrange), - _ColorSwatches( - toolLabel: 'Marker', - keyPrefix: 'marker', - hue: mode.hue, - colorForHue: _markerColor, - onSelected: (hue) => onConfigure(hue: hue), - ), - SizedBox( - width: 160, - child: Semantics( - label: 'Marker color hue', - child: Slider( - key: const Key('marker-hue'), - value: mode.hue, - min: 0, - max: 360, - activeColor: _markerColor( - mode.hue, - mode.saturation, - mode.brightness, - ), - onChanged: (hue) => onConfigure(hue: hue), - ), - ), - ), - _StyleSlider( - key: const Key('marker-saturation'), - label: 'Marker saturation', - value: mode.saturation, - color: _markerColor(mode.hue, 1, mode.brightness), - onChanged: (value) => onConfigure(saturation: value), - ), - _StyleSlider( - key: const Key('marker-brightness'), - label: 'Marker brightness', - value: mode.brightness, - color: _markerColor(mode.hue, mode.saturation, 1), - onChanged: (value) => onConfigure(brightness: value), - ), - _KeyboardMapCursor( - cursor: cursor, - isDrawing: false, - canFinishDrawing: false, - pointCount: 0, - onCommit: onPlaceAtCursor, - onFinish: () {}, - ), - _UnitActionButton( - label: 'SELECTION CURSOR', - icon: Icons.ads_click_outlined, - foreground: textPrimary, - background: tacticalTransparent, - onPressed: onSelectTool, - ), - ], - ), - ); -} - -class _MarkerNamePrompt extends StatefulWidget { - const _MarkerNamePrompt({required this.onSave, required this.onCancel}); - - final ValueChanged onSave; - final VoidCallback onCancel; - - @override - State<_MarkerNamePrompt> createState() => _MarkerNamePromptState(); -} - -class _MarkerNamePromptState extends State<_MarkerNamePrompt> { - final _controller = TextEditingController(); - final _focusNode = FocusNode(); - String? _error; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback( - (_) => _focusNode.requestFocus(), - ); - } - - @override - void dispose() { - _controller.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - void _submit() { - final name = _controller.text; - if (name.trim().isEmpty) { - setState(() => _error = 'Enter a marker name.'); - return; - } - if (name.runes.length > 48) { - setState(() => _error = 'Marker names are limited to 48 characters.'); - return; - } - widget.onSave(name); - } - - @override - Widget build(BuildContext context) => Semantics( - container: true, - scopesRoute: true, - namesRoute: true, - explicitChildNodes: true, - label: 'Name local marker', - child: _TacticalGlass( - padding: const EdgeInsets.all(14), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _HudLabel('NAME LOCAL MARKER', color: accentOrange), - const SizedBox(height: 6), - const Text('Session only · not sent to a vehicle'), - const SizedBox(height: 10), - TextField( - key: const Key('marker-name-field'), - focusNode: _focusNode, - controller: _controller, - maxLength: 48, - textInputAction: TextInputAction.done, - onSubmitted: (_) => _submit(), - decoration: InputDecoration( - labelText: 'Marker name', - errorText: _error, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - _UnitActionButton( - label: 'SAVE MARKER', - icon: Icons.check, - foreground: tacticalBlack, - background: accentMint, - onPressed: _submit, - ), - _UnitActionButton( - label: 'CANCEL', - icon: Icons.close, - foreground: textPrimary, - background: tacticalTransparent, - onPressed: widget.onCancel, - ), - ], - ), - ], - ), - ), - ); -} - -Color _markerColor(double hue, [double saturation = .68, double value = .98]) => - HSVColor.fromAHSV(1, hue, saturation, value).toColor(); - -class _StyleSlider extends StatelessWidget { - const _StyleSlider({ - super.key, - required this.label, - required this.value, - required this.color, - required this.onChanged, - }); - - final String label; - final double value; - final Color color; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) => SizedBox( - width: 130, - child: Semantics( - label: label, - child: Slider( - value: value, - min: 0, - max: 1, - activeColor: color, - onChanged: onChanged, - ), - ), - ); -} - -class _KeyboardMapCursor extends StatelessWidget { - const _KeyboardMapCursor({ - required this.cursor, - required this.isDrawing, - required this.canFinishDrawing, - required this.pointCount, - required this.onCommit, - required this.onFinish, - }); - - final LocalScenePoint cursor; - final bool isDrawing; - final bool canFinishDrawing; - final int pointCount; - final VoidCallback onCommit; - final VoidCallback onFinish; - - @override - Widget build(BuildContext context) { - final location = _qualitativeSceneLocation(cursor); - return Semantics( - liveRegion: true, - label: 'Map cursor: $location. Local scene only, not a vehicle location.', - child: Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _HudLabel('MAP CURSOR · $location', color: textSecondary), - if (isDrawing) _HudLabel('LOCAL POINTS: $pointCount'), - OutlinedButton.icon( - onPressed: onCommit, - icon: const Icon(Icons.add_location_alt_outlined, size: 16), - label: Text(isDrawing ? 'ADD LOCAL POINT' : 'PLACE AT CURSOR'), - ), - if (isDrawing) - FilledButton.icon( - onPressed: canFinishDrawing ? onFinish : null, - icon: const Icon(Icons.check, size: 16), - label: const Text('FINISH DRAWING'), - ), - ], - ), - ); - } -} - -String _qualitativeSceneLocation(LocalScenePoint point) { - final horizontal = point.x < .34 - ? 'left' - : point.x > .66 - ? 'right' - : 'center'; - final vertical = point.y < .34 - ? 'upper' - : point.y > .66 - ? 'lower' - : 'middle'; - return '$vertical $horizontal'; -} - -/// The production-style unit hangar: a horizontal, local-only RTS dock. -/// Selecting a category prepares a draft; it cannot create or configure a -/// vehicle. Confirmation creates only a local workspace unit. -class _UnitHangarDock extends ConsumerWidget { - const _UnitHangarDock({this.compact = false, required this.onClose}); - - final bool compact; - final VoidCallback onClose; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final tactical = ref.watch(tacticalControllerProvider); - final workspace = ref.watch(vehicleWorkspaceProvider); - final controller = ref.read(tacticalControllerProvider.notifier); - final panel = tactical.panel; - final draft = tactical.unitDraft; - FleetUnit? selectedLocal; - if (tactical.selectedUnitIds.length == 1) { - for (final unit in workspace.units) { - if (unit.localUnitId == tactical.selectedUnitIds.single) { - selectedLocal = unit; - break; - } - } - } - - return RepaintBoundary( - child: _TacticalGlass( - padding: EdgeInsets.all(compact ? 14 : 16), - borderRadius: BorderRadius.circular(14), - child: switch (panel) { - TacticalPanel.unitHangar => _HangarSelection( - draft: draft, - onSelect: (definition) => controller.chooseUnitDefinition( - definition, - displayName: _nextLocalUnitName( - definition, - workspace.units.length, - ), - ), - onPrepare: controller.beginUnitSetup, - onClose: onClose, - ), - TacticalPanel.unitSetup when draft != null => _DraftUnitSetup( - draft: draft, - onNameChanged: controller.updateUnitDraftName, - onBack: controller.returnToUnitHangar, - onConfirm: () { - final localUnitId = 'local-unit-${workspace.units.length + 1}'; - final created = ref - .read(vehicleWorkspaceProvider.notifier) - .addUnitAutomatically( - definition: draft.definition, - displayName: draft.displayName, - ); - if (created) { - controller.completeUnitPlacement(localUnitId); - } - }, - ), - TacticalPanel.unitSetup when selectedLocal != null => - _ExistingUnitSetup( - unit: selectedLocal, - onClose: onClose, - onSaveName: (name) => ref - .read(vehicleWorkspaceProvider.notifier) - .renameUnit(selectedLocal!.localUnitId, name), - ), - _ => const SizedBox.shrink(), - }, - ), - ); - } -} - -String _nextLocalUnitName(VehicleDefinition definition, int unitCount) => - '${definition.name.toUpperCase()}-${(unitCount + 1).toString().padLeft(2, '0')}'; - -class _HangarSelection extends StatefulWidget { - const _HangarSelection({ - required this.draft, - required this.onSelect, - required this.onPrepare, - required this.onClose, - }); - - final UnitCreationDraft? draft; - final ValueChanged onSelect; - final VoidCallback onPrepare; - final VoidCallback onClose; - - @override - State<_HangarSelection> createState() => _HangarSelectionState(); -} - -class _HangarSelectionState extends State<_HangarSelection> { - late final List _cardFocusNodes; - final ScrollController _scrollController = ScrollController(); - - @override - void initState() { - super.initState(); - _cardFocusNodes = List.generate( - supportedVehicleDefinitions.length, - (index) => FocusNode(debugLabel: 'hangar-card-$index'), - ); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _cardFocusNodes.first.requestFocus(); - } - }); - } - - @override - void dispose() { - _scrollController.dispose(); - for (final node in _cardFocusNodes) { - node.dispose(); - } - super.dispose(); - } - - void _choose(int index) => - widget.onSelect(supportedVehicleDefinitions[index]); - - void _moveFocus(int target) { - final index = target.clamp(0, supportedVehicleDefinitions.length - 1); - _choose(index); - _cardFocusNodes[index].requestFocus(); - final context = _cardFocusNodes[index].context; - if (context != null) { - Scrollable.ensureVisible( - context, - alignment: .5, - duration: const Duration(milliseconds: 120), - ); - } - } - - int get _selectedIndex { - final id = widget.draft?.definition.id; - return supportedVehicleDefinitions.indexWhere( - (definition) => definition.id == id, - ); - } - - @override - Widget build(BuildContext context) => FocusTraversalGroup( - child: Semantics( - container: true, - label: 'Unit hangar. Local scene vehicle categories.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - Icons.inventory_2_outlined, - color: accentMint, - size: 18, - ), - const SizedBox(width: 8), - const _HudLabel('UNIT HANGAR', color: textPrimary), - const SizedBox(width: 8), - const _Token(label: 'LOCAL SCENE', tone: textSecondary), - const Spacer(), - IconButton( - tooltip: 'Close unit hangar', - onPressed: widget.onClose, - icon: const Icon(Icons.close_outlined), - ), - ], - ), - const SizedBox(height: 8), - Expanded( - child: Shortcuts( - shortcuts: const { - SingleActivator(LogicalKeyboardKey.arrowLeft): - _HangarMoveIntent(-1), - SingleActivator(LogicalKeyboardKey.arrowRight): - _HangarMoveIntent(1), - SingleActivator(LogicalKeyboardKey.home): _HangarHomeIntent(), - SingleActivator(LogicalKeyboardKey.end): _HangarEndIntent(), - }, - child: Actions( - actions: { - _HangarMoveIntent: CallbackAction<_HangarMoveIntent>( - onInvoke: (intent) { - _moveFocus( - (_selectedIndex < 0 ? 0 : _selectedIndex) + - intent.delta, - ); - return null; - }, - ), - _HangarHomeIntent: CallbackAction<_HangarHomeIntent>( - onInvoke: (_) { - _moveFocus(0); - return null; - }, - ), - _HangarEndIntent: CallbackAction<_HangarEndIntent>( - onInvoke: (_) { - _moveFocus(supportedVehicleDefinitions.length - 1); - return null; - }, - ), - }, - child: Scrollbar( - controller: _scrollController, - child: ListView.separated( - key: const Key('unit-hangar-strip'), - controller: _scrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.only(bottom: 2), - itemCount: supportedVehicleDefinitions.length, - separatorBuilder: (context, index) => - const SizedBox(width: 10), - itemBuilder: (context, index) { - final definition = supportedVehicleDefinitions[index]; - return _HangarVehicleCard( - key: Key('hangar-card-${definition.id}'), - focusNode: _cardFocusNodes[index], - autofocus: index == 0, - definition: definition, - selected: widget.draft?.definition.id == definition.id, - onSelected: () => _choose(index), - ); - }, - ), - ), - ), - ), - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: _HudLabel( - widget.draft == null - ? 'SELECT A LOCAL UNIT TYPE' - : '${widget.draft!.definition.name.toUpperCase()} SELECTED', - ), - ), - _UnitActionButton( - label: 'PREPARE UNIT', - icon: Icons.tune_outlined, - foreground: tacticalBlack, - background: accentMint, - onPressed: widget.draft == null ? null : widget.onPrepare, - ), - ], - ), - ], - ), - ), - ); -} - -/// Image-first card for the low, horizontal RTS hangar. Unlike the former -/// workspace-selection card, it has exactly one job: choose a draft type. -/// Creating a unit remains an explicit setup confirmation. -class _HangarVehicleCard extends StatefulWidget { - const _HangarVehicleCard({ - super.key, - required this.definition, - required this.selected, - required this.onSelected, - required this.focusNode, - required this.autofocus, - }); - - final VehicleDefinition definition; - final bool selected; - final VoidCallback onSelected; - final FocusNode focusNode; - final bool autofocus; - - @override - State<_HangarVehicleCard> createState() => _HangarVehicleCardState(); -} - -class _HangarVehicleCardState extends State<_HangarVehicleCard> { - bool _focused = false; - - @override - Widget build(BuildContext context) { - final active = widget.selected || _focused; - return Semantics( - button: true, - checked: widget.selected, - inMutuallyExclusiveGroup: true, - label: - '${widget.definition.name}, ${widget.definition.vehicleClass}, ' - 'local unit category', - onTap: widget.onSelected, - child: SizedBox( - width: 186, - child: Material( - color: Colors.transparent, - child: InkWell( - focusNode: widget.focusNode, - autofocus: widget.autofocus, - onFocusChange: (value) => setState(() => _focused = value), - onTap: widget.onSelected, - borderRadius: BorderRadius.circular(9), - child: AnimatedContainer( - duration: const Duration(milliseconds: 110), - curve: Curves.easeOut, - padding: const EdgeInsets.all(5), - decoration: BoxDecoration( - color: active - ? accentMint.withValues(alpha: .12) - : tacticalBlack.withValues(alpha: .48), - border: Border.all( - color: active ? accentMint : panelBorder, - width: active ? 1.5 : 1, - ), - borderRadius: BorderRadius.circular(9), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(5), - child: Image.asset( - widget.definition.media.imageAssetPath, - width: double.infinity, - fit: BoxFit.cover, - filterQuality: FilterQuality.high, - excludeFromSemantics: true, - ), - ), - ), - const SizedBox(height: 6), - _HudLabel(widget.definition.vehicleClass), - const SizedBox(height: 2), - Row( - children: [ - Icon( - widget.selected - ? Icons.radio_button_checked - : Icons.radio_button_off, - color: widget.selected ? accentMint : textSecondary, - size: 14, - ), - const SizedBox(width: 5), - Expanded( - child: Text( - widget.definition.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: GoogleFonts.inter( - color: textPrimary, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _HangarMoveIntent extends Intent { - const _HangarMoveIntent(this.delta); - - final int delta; -} - -class _HangarHomeIntent extends Intent { - const _HangarHomeIntent(); -} - -class _HangarEndIntent extends Intent { - const _HangarEndIntent(); -} - -class _DraftUnitSetup extends StatefulWidget { - const _DraftUnitSetup({ - required this.draft, - required this.onNameChanged, - required this.onBack, - required this.onConfirm, - }); - - final UnitCreationDraft draft; - final ValueChanged onNameChanged; - final VoidCallback onBack; - final VoidCallback onConfirm; - - @override - State<_DraftUnitSetup> createState() => _DraftUnitSetupState(); -} - -class _DraftUnitSetupState extends State<_DraftUnitSetup> { - late final TextEditingController _nameController; - String? _error; - - @override - void initState() { - super.initState(); - _nameController = TextEditingController(text: widget.draft.displayName); - } - - @override - void didUpdateWidget(covariant _DraftUnitSetup oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.draft.definition.id != widget.draft.definition.id) { - _nameController.text = widget.draft.displayName; - } - } - - @override - void dispose() { - _nameController.dispose(); - super.dispose(); - } - - void _confirm() { - if (!VehicleWorkspaceController.isValidLocalDisplayName( - _nameController.text, - )) { - setState( - () => _error = 'Enter a local unit name of up to 32 characters.', - ); - return; - } - setState(() => _error = null); - widget.onConfirm(); - } - - @override - Widget build(BuildContext context) => _UnitSetupLayout( - label: 'UNIT SETUP', - definition: widget.draft.definition, - nameController: _nameController, - editable: true, - error: _error, - onNameChanged: (value) { - if (_error != null && - VehicleWorkspaceController.isValidLocalDisplayName(value)) { - setState(() => _error = null); - } - widget.onNameChanged(value); - }, - primaryLabel: 'ADD TO LOCAL SCENE', - onPrimary: _confirm, - secondaryLabel: 'BACK TO HANGAR', - onSecondary: widget.onBack, - ); -} - -class _ExistingUnitSetup extends StatefulWidget { - const _ExistingUnitSetup({ - required this.unit, - required this.onClose, - required this.onSaveName, - }); - - final FleetUnit unit; - final VoidCallback onClose; - final bool Function(String name) onSaveName; - - @override - State<_ExistingUnitSetup> createState() => _ExistingUnitSetupState(); -} - -class _ExistingUnitSetupState extends State<_ExistingUnitSetup> { - late final TextEditingController _nameController; - String? _error; - - @override - void initState() { - super.initState(); - _nameController = TextEditingController(text: widget.unit.displayName); - } - - @override - void dispose() { - _nameController.dispose(); - super.dispose(); - } - - void _save() { - if (!widget.onSaveName(_nameController.text)) { - setState( - () => _error = 'Enter a local unit name of up to 32 characters.', - ); - return; - } - setState(() => _error = null); - } - - @override - Widget build(BuildContext context) => _UnitSetupLayout( - label: 'LOCAL UNIT SETUP', - definition: widget.unit.definition, - nameController: _nameController, - editable: true, - error: _error, - onNameChanged: (_) {}, - primaryLabel: 'SAVE LOCAL SETUP', - onPrimary: _save, - secondaryLabel: 'CLOSE', - onSecondary: widget.onClose, - ); -} - -class _UnitSetupLayout extends StatelessWidget { - const _UnitSetupLayout({ - required this.label, - required this.definition, - required this.nameController, - required this.editable, - required this.onNameChanged, - required this.primaryLabel, - required this.onPrimary, - required this.secondaryLabel, - required this.onSecondary, - this.error, - }); - - final String label; - final VehicleDefinition definition; - final TextEditingController nameController; - final bool editable; - final ValueChanged onNameChanged; - final String primaryLabel; - final VoidCallback onPrimary; - final String secondaryLabel; - final VoidCallback onSecondary; - final String? error; - - @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) { - final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; - // A tall stacked setup remains scrollable inside the compact dock at - // large text sizes, rather than compressing the identity/form columns. - final stacked = constraints.maxWidth < 760 || textScale >= 1.25; - final identity = _UnitSetupIdentity(definition: definition); - final fields = _UnitSetupFields( - definition: definition, - nameController: nameController, - editable: editable, - error: error, - onNameChanged: onNameChanged, - primaryLabel: primaryLabel, - onPrimary: onPrimary, - secondaryLabel: secondaryLabel, - onSecondary: onSecondary, - ); - return Semantics( - container: true, - label: - '$label. Local workspace only. No vehicle link or command authority.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _HudLabel(label, color: textPrimary), - const SizedBox(height: 10), - Expanded( - child: stacked - ? ListView( - children: [identity, const SizedBox(height: 12), fields], - ) - : Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox(width: 260, child: identity), - const SizedBox(width: 18), - Expanded(child: fields), - ], - ), - ), - ], - ), - ); - }, - ); -} - -class _UnitSetupIdentity extends StatelessWidget { - const _UnitSetupIdentity({required this.definition}); - - final VehicleDefinition definition; - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - height: 104, - width: double.infinity, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.asset( - definition.media.imageAssetPath, - fit: BoxFit.cover, - excludeFromSemantics: true, - ), - ), - ), - const SizedBox(height: 8), - _HudLabel(definition.vehicleClass), - Text(definition.name, style: Theme.of(context).textTheme.titleMedium), - const _HudLabel('LOCAL SCENE UNIT'), - ], - ); -} - -class _UnitSetupFields extends StatelessWidget { - const _UnitSetupFields({ - required this.definition, - required this.nameController, - required this.editable, - required this.onNameChanged, - required this.primaryLabel, - required this.onPrimary, - required this.secondaryLabel, - required this.onSecondary, - this.error, - }); - - final VehicleDefinition definition; - final TextEditingController nameController; - final bool editable; - final ValueChanged onNameChanged; - final String primaryLabel; - final VoidCallback onPrimary; - final String secondaryLabel; - final VoidCallback onSecondary; - final String? error; - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - key: const Key('local-unit-name-field'), - controller: nameController, - enabled: editable, - maxLength: 32, - onChanged: onNameChanged, - decoration: InputDecoration( - labelText: 'Unit name', - errorText: error, - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - _SetupFact(label: 'TYPE', value: definition.name), - const SizedBox(height: 6), - const _SetupFact(label: 'PLACEMENT', value: 'AUTOMATIC LOCAL POSITION'), - const SizedBox(height: 10), - const _ConfigurationUnavailable(), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _UnitActionButton( - label: primaryLabel, - icon: Icons.check_outlined, - foreground: tacticalBlack, - background: accentMint, - onPressed: onPrimary, - ), - _UnitActionButton( - label: secondaryLabel, - icon: Icons.arrow_back_outlined, - foreground: textPrimary, - background: tacticalTransparent, - onPressed: onSecondary, - ), - ], - ), - ], - ); -} - -class _SetupFact extends StatelessWidget { - const _SetupFact({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) => Row( - children: [ - SizedBox(width: 94, child: _HudLabel(label)), - Expanded(child: _MonoText(value, color: textPrimary, size: 10)), - ], - ); -} - -class _ConfigurationUnavailable extends StatelessWidget { - const _ConfigurationUnavailable(); - - @override - Widget build(BuildContext context) => Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: textSecondary.withValues(alpha: .08), - border: Border.all(color: panelBorder), - borderRadius: BorderRadius.circular(8), - ), - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _HudLabel('VEHICLE CONFIGURATION UNAVAILABLE'), - SizedBox(height: 3), - Text( - 'Unavailable until verified binding, compatible schema, command authority, and safe activation are available.', - ), - ], - ), - ); -} - -/// Coalesces pointer movement to the next frame and publishes it only to the -/// map-preview provider. The command reducer never receives hover traffic. -class _TacticalPointerInput extends ConsumerStatefulWidget { - const _TacticalPointerInput({ - required this.mode, - required this.onPoint, - required this.onStrokeComplete, - required this.onCancel, - }); - - final TacticalMode mode; - final ValueChanged onPoint; - final ValueChanged> onStrokeComplete; - final VoidCallback onCancel; - - @override - ConsumerState<_TacticalPointerInput> createState() => - _TacticalPointerInputState(); -} - -class _TacticalPointerInputState extends ConsumerState<_TacticalPointerInput> { - // About one logical pixel on a typical desktop map. We coalesce updates to - // one frame, then retain a bounded high-detail preview so slow curves do not - // turn into coarse straight segments. - static const _maximumStrokeSamples = 512; - static const _minimumSampleDistanceSquared = .000001; - - LocalScenePoint? _pending; - final List _stroke = []; - int? _activePrimaryPointer; - bool _frameScheduled = false; - late final TacticalPointerPreviewController _previewController; - - @override - void initState() { - super.initState(); - _previewController = ref.read(tacticalPointerPreviewProvider.notifier); - } - - @override - Widget build(BuildContext context) => Listener( - behavior: HitTestBehavior.translucent, - onPointerMove: _queuePreview, - onPointerHover: _queueHoverPreview, - onPointerDown: (event) { - if (event.buttons == kSecondaryButton) { - _activePrimaryPointer = null; - _clearPreview(); - widget.onCancel(); - return; - } - if (event.buttons != kPrimaryButton) { - return; - } - final point = _pointFor(event.position); - if (point != null) { - _activePrimaryPointer = event.pointer; - _stroke - ..clear() - ..add(point); - _previewController.show(point, stroke: _stroke); - } - }, - onPointerUp: (event) { - if (event.pointer != _activePrimaryPointer) { - return; - } - _activePrimaryPointer = null; - _flushPending(); - final point = _pointFor(event.position); - if (point == null) { - _clearPreview(); - return; - } - _appendDistinct(point); - final stroke = List.of(_stroke); - final mode = widget.mode; - if (mode is TacticalPencilMode) { - widget.onStrokeComplete(stroke); - } else if (mode is TacticalMarkerMode) { - widget.onPoint(point); - } - _clearPreview(); - }, - onPointerCancel: (event) { - if (event.pointer == _activePrimaryPointer) { - _activePrimaryPointer = null; - _clearPreview(); - } - }, - child: const ExcludeSemantics(child: SizedBox.expand()), - ); - - @override - void dispose() { - // A mode transition clears the provider before this input unmounts. Do not - // mutate a Riverpod provider from disposal; it can run while the widget - // tree is being rebuilt. Clearing the pending sample prevents a queued - // frame callback from restoring the preview after unmount. - _pending = null; - super.dispose(); - } - - void _clearPreview() { - _pending = null; - _stroke.clear(); - _previewController.clear(); - } - - void _queuePreview(PointerMoveEvent event) { - if (event.pointer != _activePrimaryPointer) { - return; - } - _schedulePreview(_pointFor(event.position)); - } - - void _queueHoverPreview(PointerHoverEvent event) { - if (_activePrimaryPointer != null || widget.mode is! TacticalMarkerMode) { - return; - } - _schedulePreview(_pointFor(event.position)); - } - - void _schedulePreview(LocalScenePoint? point) { - _pending = point; - if (_frameScheduled || _pending == null) { - return; - } - _frameScheduled = true; - SchedulerBinding.instance.scheduleFrameCallback((_) { - _frameScheduled = false; - final point = _pending; - _pending = null; - if (!mounted || point == null) { - return; - } - if (_activePrimaryPointer != null && - widget.mode is TacticalPencilMode && - _stroke.isNotEmpty) { - _appendDistinct(point); - } - _previewController.show(point, stroke: _stroke); - }); - } - - void _flushPending() { - final point = _pending; - _pending = null; - if (point != null && widget.mode is TacticalPencilMode) { - _appendDistinct(point); - } - } - - void _appendDistinct(LocalScenePoint point) { - if (_stroke.isEmpty) { - _stroke.add(point); - return; - } - final previous = _stroke.last; - final dx = previous.x - point.x; - final dy = previous.y - point.y; - if (dx * dx + dy * dy < _minimumSampleDistanceSquared) { - return; - } - if (_stroke.length >= _maximumStrokeSamples) { - _stroke[_stroke.length - 1] = point; - } else { - _stroke.add(point); - } - } - - LocalScenePoint? _pointFor(Offset globalPosition) { - final box = context.findRenderObject() as RenderBox?; - if (box == null || box.size.isEmpty) { - return null; - } - final local = box.globalToLocal(globalPosition); - return LocalScenePoint( - x: (local.dx / box.size.width).clamp(0.0, 1.0), - y: (local.dy / box.size.height).clamp(0.0, 1.0), - ); - } -} - -class _TopNavigation extends StatelessWidget { - const _TopNavigation({ - required this.sourceLabel, - required this.sourceStatus, - required this.onOpenEventStream, - this.compact = false, - }); - - final bool compact; - final String sourceLabel; - final String sourceStatus; - final VoidCallback onOpenEventStream; - - @override - Widget build(BuildContext context) => _TacticalGlass( - padding: EdgeInsets.symmetric(horizontal: compact ? 14 : 20, vertical: 12), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - const _BrandMark(), - const SizedBox(width: 24), - const _HeaderFact(label: 'MODE', value: 'TACTICAL COMMAND'), - const SizedBox(width: 24), - const _HeaderFact(label: 'DATA', value: 'LOCAL SCENE'), - const SizedBox(width: 24), - _HeaderFact(label: 'SOURCE', value: '$sourceLabel · $sourceStatus'), - const SizedBox(width: 16), - TextButton.icon( - onPressed: onOpenEventStream, - icon: const Icon(Icons.subject_outlined, size: 16), - label: const Text('EVENT STREAM'), - ), - const SizedBox(width: 16), - const _PhaseToggle(), - const SizedBox(width: 28), - const _HeaderTimer( - icon: Icons.timer_outlined, - label: 'SCENE TIME', - value: '00:55:08', - ), - const SizedBox(width: 20), - const _HeaderTimer( - icon: Icons.flight_outlined, - label: 'RENDER TIME', - value: '00:36:03', - ), - ], - ), - ), - ); -} - -class _BrandMark extends StatelessWidget { - const _BrandMark(); - - @override - Widget build(BuildContext context) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: accentMint.withValues(alpha: .12), - border: Border.all(color: accentMint.withValues(alpha: .6)), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.flight_takeoff, color: accentMint, size: 19), - ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'KAPTAN', - style: GoogleFonts.inter( - color: textPrimary, - fontSize: 14, - fontWeight: FontWeight.w800, - letterSpacing: 1.4, - ), - ), - const _HudLabel('GROUND SUITE'), - ], - ), - ], - ); -} - -class _HeaderFact extends StatelessWidget { - const _HeaderFact({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _HudLabel(label), - const SizedBox(height: 4), - Text( - value, - style: GoogleFonts.inter( - color: textPrimary, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - ], - ); -} - -class _PhaseToggle extends StatelessWidget { - const _PhaseToggle(); - - @override - Widget build(BuildContext context) { - const tone = accentOrange; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: tone.withValues(alpha: .16), - border: Border.all(color: tone.withValues(alpha: .65)), - borderRadius: BorderRadius.circular(7), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.auto_mode, color: tone, size: 16), - const SizedBox(width: 7), - _HudLabel('PHASE · LOCAL', color: tone), - ], - ), - ); - } -} - -class _HeaderTimer extends StatelessWidget { - const _HeaderTimer({ - required this.icon, - required this.label, - required this.value, - }); - - final IconData icon; - final String label; - final String value; - - @override - Widget build(BuildContext context) => Row( - children: [ - Icon(icon, color: textSecondary, size: 16), - const SizedBox(width: 7), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _HudLabel(label), - const SizedBox(height: 3), - _MonoText(value, color: textPrimary, size: 12), - ], - ), - ], - ); -} - -class _FpvPanel extends StatelessWidget { - const _FpvPanel({required this.selectedUnit}); - - final _DroneData selectedUnit; - - @override - Widget build(BuildContext context) => _TacticalGlass( - padding: const EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 7, - runSpacing: 6, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - const _HudLabel('SIMULATED CAMERA CONCEPT'), - _Token(label: selectedUnit.id, tone: accentMint), - _Token(label: 'NOT VIDEO', tone: accentOrange), - ], - ), - const SizedBox(height: 9), - Expanded( - child: Semantics( - label: 'Simulated camera concept. Not vehicle video.', - child: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: ExcludeSemantics( - child: Stack( - fit: StackFit.expand, - children: [ - Image.network( - _fpvPlaceholder, - fit: BoxFit.cover, - cacheWidth: 1280, - filterQuality: FilterQuality.medium, - loadingBuilder: (context, child, progress) => - progress == null - ? child - : const ColoredBox(color: panelBackground), - errorBuilder: (context, error, stackTrace) => - const ColoredBox( - color: panelBackground, - child: Center( - child: _UnavailableVisual( - icon: Icons.videocam_off_outlined, - label: 'DEMO CAMERA VISUAL UNAVAILABLE', - ), - ), - ), - ), - ColoredBox(color: tacticalBlack.withValues(alpha: .22)), - CustomPaint(painter: _FpvHudPainter()), - const Positioned( - left: 16, - top: 18, - child: _FpvScale( - label: 'SPD', - value: '34.2', - unit: 'km/h', - ), - ), - const Positioned( - right: 16, - top: 18, - child: _FpvScale( - label: 'ALT', - value: '252', - unit: 'm', - alignEnd: true, - ), - ), - ], - ), - ), - ), - ), - ), - ], - ), - ); -} - -class _FpvScale extends StatelessWidget { - const _FpvScale({ - required this.label, - required this.value, - required this.unit, - this.alignEnd = false, - }); - - final String label; - final String value; - final String unit; - final bool alignEnd; - - @override - Widget build(BuildContext context) { - const tone = accentMint; - return Column( - crossAxisAlignment: alignEnd - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - _HudLabel(label, color: tone), - const SizedBox(height: 6), - _MonoText(value, color: tone, size: 22, weight: FontWeight.w500), - _HudLabel(unit, color: tone), - ], - ); - } -} - -class _UnitCommandTray extends StatelessWidget { - const _UnitCommandTray({ - required this.selectedUnit, - required this.selectedUnitIds, - required this.onUnitSelected, - required this.onAddUnit, - required this.onOpenSetup, - required this.workspaceUnits, - }); - - final _DroneData? selectedUnit; - final Set selectedUnitIds; - final ValueChanged onUnitSelected; - final VoidCallback onAddUnit; - final VoidCallback onOpenSetup; - final List workspaceUnits; - - @override - Widget build(BuildContext context) { - if (selectedUnitIds.isEmpty) { - return _FleetSummaryConsole( - localUnitCount: workspaceUnits.length, - onAddUnit: onAddUnit, - ); - } - if (selectedUnitIds.length > 1) { - return _MultiUnitConsole( - selectedCount: selectedUnitIds.length, - onClear: () => onUnitSelected(''), - ); - } - if (selectedUnit == null) { - final local = workspaceUnits.where( - (unit) => selectedUnitIds.contains(unit.localUnitId), - ); - return _LocalWorkspaceUnitConsole( - unit: local.isEmpty ? null : local.single, - onAddUnit: onAddUnit, - onOpenSetup: onOpenSetup, - ); - } - final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; - final selectorHeight = textScale >= 1.25 ? 112.0 : 54.0; - return _TacticalGlass( - padding: const EdgeInsets.all(10), - borderRadius: BorderRadius.circular(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - Icons.view_in_ar_outlined, - color: accentMint, - size: 16, - ), - const SizedBox(width: 7), - const Expanded(child: _HudLabel('LOCAL SCENE SELECTION')), - const _Token(label: 'SIMULATED', tone: textSecondary), - ], - ), - const SizedBox(height: 8), - SizedBox( - height: selectorHeight, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _fleet.length, - separatorBuilder: (context, index) => const SizedBox(width: 7), - itemBuilder: (context, index) => _UnitSelectorCard( - data: _fleet[index], - selected: _fleet[index].id == selectedUnit!.id, - deployed: false, - onTap: () => onUnitSelected(_fleet[index].id), - ), - ), - ), - const SizedBox(height: 8), - _LocalScenePreviewBar(unit: selectedUnit!), - ], - ), - ); - } -} - -class _FleetSummaryConsole extends StatelessWidget { - const _FleetSummaryConsole({ - required this.localUnitCount, - required this.onAddUnit, - }); - - final int localUnitCount; - final VoidCallback onAddUnit; - - @override - Widget build(BuildContext context) => _TacticalGlass( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - const Icon(Icons.groups_2_outlined, color: accentMint), - const SizedBox(width: 10), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _HudLabel('LOCAL FLEET SUMMARY'), - const SizedBox(height: 4), - _MonoText( - '$localUnitCount LOCAL UNIT${localUnitCount == 1 ? '' : 'S'}', - color: textPrimary, - size: 12, - weight: FontWeight.w700, - ), - ], - ), - ), - _UnitActionButton( - key: const Key('add-local-unit-console'), - label: 'ADD UNIT', - icon: Icons.add, - foreground: tacticalBlack, - background: accentMint, - onPressed: onAddUnit, - ), - ], - ), - ); -} - -class _MultiUnitConsole extends StatelessWidget { - const _MultiUnitConsole({required this.selectedCount, required this.onClear}); - - final int selectedCount; - final VoidCallback onClear; - - @override - Widget build(BuildContext context) => _TacticalGlass( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - const Icon(Icons.select_all_outlined, color: accentMint), - const SizedBox(width: 10), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _HudLabel('LOCAL GROUP SELECTION'), - const SizedBox(height: 4), - _MonoText( - '$selectedCount UNITS SELECTED', - color: textPrimary, - size: 12, - weight: FontWeight.w700, - ), - ], - ), - ), - _UnitActionButton( - label: 'CLEAR', - icon: Icons.close, - foreground: textPrimary, - background: tacticalTransparent, - onPressed: onClear, - ), - ], - ), - ); -} - -class _LocalWorkspaceUnitConsole extends StatelessWidget { - const _LocalWorkspaceUnitConsole({ - required this.unit, - required this.onAddUnit, - required this.onOpenSetup, - }); - - final FleetUnit? unit; - final VoidCallback onAddUnit; - final VoidCallback onOpenSetup; - - @override - Widget build(BuildContext context) => _TacticalGlass( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - const Icon(Icons.flight_outlined, color: accentMint), - const SizedBox(width: 10), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _HudLabel('LOCAL WORKSPACE UNIT'), - const SizedBox(height: 4), - _MonoText( - unit?.displayName ?? 'UNBOUND UNIT', - color: textPrimary, - size: 12, - weight: FontWeight.w700, - ), - const SizedBox(height: 3), - _HudLabel( - '${unit?.definition.name.toUpperCase() ?? 'LOCAL UNIT'} · LOCAL SETUP', - ), - const SizedBox(height: 3), - const _HudLabel('NO SOURCE · NO POSITION · NO AUTHORITY'), - ], - ), - ), - Wrap( - spacing: 8, - children: [ - _UnitActionButton( - label: 'SET UP', - icon: Icons.tune_outlined, - foreground: tacticalBlack, - background: accentMint, - onPressed: onOpenSetup, - ), - _UnitActionButton( - label: 'ADD', - icon: Icons.add, - foreground: textPrimary, - background: tacticalTransparent, - onPressed: onAddUnit, - ), - ], - ), - ], - ), - ); -} - -class _UnitSelectorCard extends StatelessWidget { - const _UnitSelectorCard({ - required this.data, - required this.selected, - required this.deployed, - required this.onTap, - }); - - final _DroneData data; - final bool selected; - final bool deployed; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) => Semantics( - button: true, - selected: selected, - label: '${data.id}, simulated local scene unit', - child: InkWell( - key: Key('unit-card-${data.id}'), - onTap: onTap, - borderRadius: BorderRadius.circular(6), - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - width: 112, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7), - decoration: BoxDecoration( - color: selected ? accentMint.withValues(alpha: .1) : panelBackground, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: selected ? accentMint : panelBorder), - ), - child: Row( - children: [ - Icon( - Icons.flight, - color: selected ? accentMint : data.statusColor, - size: 18, - ), - const SizedBox(width: 6), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _MonoText(data.id, color: textPrimary, size: 8), - const SizedBox(height: 2), - Text( - 'LOCAL PREVIEW', - maxLines: 1, - softWrap: false, - overflow: TextOverflow.fade, - style: GoogleFonts.inter( - color: textSecondary, - fontSize: 7, - fontWeight: FontWeight.w700, - letterSpacing: .45, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ); -} - -class _LocalScenePreviewBar extends StatelessWidget { - const _LocalScenePreviewBar({required this.unit}); - - final _DroneData unit; - - @override - Widget build(BuildContext context) => Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _MonoText(unit.id, color: textPrimary, size: 10), - const SizedBox(height: 3), - const _HudLabel('LOCAL VISUAL PREVIEW · NO VEHICLE ACTION'), - ], - ), - ), - const Icon(Icons.lock_outline, color: textSecondary, size: 17), - ], - ); -} - -class _UnitActionButton extends StatelessWidget { - const _UnitActionButton({ - super.key, - required this.label, - required this.icon, - required this.foreground, - required this.background, - required this.onPressed, - }); - - final String label; - final IconData icon; - final Color foreground; - final Color background; - final VoidCallback? onPressed; - - @override - Widget build(BuildContext context) => SizedBox( - height: 30, - child: TextButton.icon( - onPressed: onPressed, - style: TextButton.styleFrom( - foregroundColor: foreground, - backgroundColor: background, - padding: const EdgeInsets.symmetric(horizontal: 8), - side: BorderSide(color: foreground.withValues(alpha: .7)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), - ), - icon: Icon(icon, size: 14), - label: Text( - label, - style: GoogleFonts.inter( - color: foreground, - fontSize: 8, - fontWeight: FontWeight.w800, - letterSpacing: .45, - ), - ), - ), - ); -} - -class _TacticalGlass extends StatelessWidget { - const _TacticalGlass({ - required this.child, - this.padding = EdgeInsets.zero, - this.borderRadius = const BorderRadius.all(Radius.circular(12)), - }); - - final Widget child; - final EdgeInsets padding; - final BorderRadius borderRadius; - - @override - Widget build(BuildContext context) { - final highContrast = MediaQuery.maybeOf(context)?.highContrast ?? false; - final surface = Container( - padding: padding, - decoration: BoxDecoration( - color: highContrast ? const Color(0xFF12141A) : panelBackground, - borderRadius: borderRadius, - border: Border.all( - color: highContrast ? textPrimary : panelBorder, - width: highContrast ? 2 : 1, - ), - ), - child: child, - ); - if (highContrast) { - return RepaintBoundary(child: surface); - } - return RepaintBoundary( - child: ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), - child: surface, - ), - ), - ); - } -} - -class _Token extends StatelessWidget { - const _Token({required this.label, required this.tone}); - - final String label; - final Color tone; - - @override - Widget build(BuildContext context) => Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), - decoration: BoxDecoration( - color: tone.withValues(alpha: .12), - border: Border.all(color: tone.withValues(alpha: .55)), - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [_HudLabel(label, color: tone)], - ), - ); -} - -class _HudLabel extends StatelessWidget { - const _HudLabel(this.value, {this.color}); - - final String value; - final Color? color; - - @override - Widget build(BuildContext context) => Text( - value, - style: GoogleFonts.inter( - color: color ?? textSecondary, - fontSize: 9, - fontWeight: FontWeight.w700, - letterSpacing: .85, - ), - ); -} - -class _MonoText extends StatelessWidget { - const _MonoText( - this.value, { - this.color, - this.size = 10, - this.weight = FontWeight.w400, - }); - - final String value; - final Color? color; - final double size; - final FontWeight weight; - - @override - Widget build(BuildContext context) => Text( - value, - style: GoogleFonts.robotoMono( - color: color ?? textSecondary, - fontSize: size, - fontWeight: weight, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ); -} - -class _DroneData { - const _DroneData( - this.id, - this.speed, - this.altitude, - this.status, - this.statusColor, - this.battery, - ); - - final String id; - final String speed; - final String altitude; - final String status; - final Color statusColor; - final double battery; -} - -class _MapGridPainter extends CustomPainter { - const _MapGridPainter(); - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = accentMint.withValues(alpha: .07) - ..strokeWidth = 1; - const step = 82.0; - for (var x = 0.0; x < size.width; x += step) { - canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); - } - for (var y = 0.0; y < size.height; y += step) { - canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); - } - } - - @override - bool shouldRepaint(covariant _MapGridPainter oldDelegate) => false; -} - -class _DemoMapOverlayPainter extends CustomPainter { - const _DemoMapOverlayPainter({ - required this.drawings, - required this.markers, - required this.draftPencil, - required this.draftMarker, - required this.pointerPreview, - required this.previewStroke, - }); - - final List drawings; - final List markers; - final TacticalPencilMode? draftPencil; - final TacticalMarkerMode? draftMarker; - final LocalScenePoint? pointerPreview; - final List previewStroke; - - @override - void paint(Canvas canvas, Size size) { - for (final drawing in drawings) { - _drawDrawing( - canvas, - size, - drawing.points, - color: _pencilColor( - drawing.hue, - drawing.saturation, - drawing.brightness, - ), - thickness: drawing.thickness, - ); - } - final draft = draftPencil; - if (draft != null && draft.keyboardPoints.isNotEmpty) { - _drawDrawing( - canvas, - size, - draft.keyboardPoints, - color: _pencilColor(draft.hue, draft.saturation, draft.brightness), - thickness: draft.thickness, - draft: true, - ); - } - if (draft != null && previewStroke.isNotEmpty) { - _drawDrawing( - canvas, - size, - previewStroke, - color: _pencilColor(draft.hue, draft.saturation, draft.brightness), - thickness: draft.thickness, - draft: true, - ); - } - final preview = pointerPreview; - if (preview != null && draft != null) { - final offset = Offset(preview.x * size.width, preview.y * size.height); - canvas.drawCircle( - offset, - 8, - Paint() - ..color = _pencilColor( - draft.hue, - draft.saturation, - draft.brightness, - ).withValues(alpha: .9) - ..style = PaintingStyle.stroke - ..strokeWidth = draft.thickness, - ); - } - final marker = draftMarker; - if (marker != null && preview != null && marker.pendingPoint == null) { - _drawMarkerPreview( - canvas, - size, - preview, - color: _markerColor(marker.hue, marker.saturation, marker.brightness), - ); - } - if (marker?.pendingPoint case final point?) { - _drawMarkerPreview( - canvas, - size, - point, - color: _markerColor(marker!.hue, marker.saturation, marker.brightness), - pending: true, - ); - } - } - - void _drawMarkerPreview( - Canvas canvas, - Size size, - LocalScenePoint point, { - required Color color, - bool pending = false, - }) { - final offset = Offset(point.x * size.width, point.y * size.height); - final paint = Paint() - ..color = color.withValues(alpha: pending ? .95 : .72) - ..style = PaintingStyle.fill; - final pin = Path() - ..moveTo(offset.dx, offset.dy + 15) - ..cubicTo( - offset.dx - 11, - offset.dy + 4, - offset.dx - 9, - offset.dy - 13, - offset.dx, - offset.dy - 13, - ) - ..cubicTo( - offset.dx + 9, - offset.dy - 13, - offset.dx + 11, - offset.dy + 4, - offset.dx, - offset.dy + 15, - ) - ..close(); - canvas.drawPath(pin, paint); - canvas.drawCircle( - Offset(offset.dx, offset.dy - 3), - 3, - Paint()..color = tacticalBlack, - ); - } - - void _drawDrawing( - Canvas canvas, - Size size, - List points, { - required Color color, - required double thickness, - bool draft = false, - }) { - final paint = Paint() - ..color = color.withValues(alpha: draft ? .75 : .9) - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round - ..strokeWidth = draft ? math.max(1, thickness * .75) : thickness; - final offsets = points - .map((point) => Offset(point.x * size.width, point.y * size.height)) - .toList(growable: false); - if (offsets.isEmpty) { - return; - } - final path = Path()..moveTo(offsets.first.dx, offsets.first.dy); - for (final offset in offsets.skip(1)) { - path.lineTo(offset.dx, offset.dy); - } - canvas.drawPath(path, paint); - } - - @override - bool shouldRepaint(covariant _DemoMapOverlayPainter oldDelegate) => - !identical(drawings, oldDelegate.drawings) || - !identical(markers, oldDelegate.markers) || - draftPencil != oldDelegate.draftPencil || - draftMarker != oldDelegate.draftMarker || - pointerPreview != oldDelegate.pointerPreview || - !identical(previewStroke, oldDelegate.previewStroke); -} - -class _FpvHudPainter extends CustomPainter { - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = accentMint - ..style = PaintingStyle.stroke - ..strokeWidth = 1.5 - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4); - final center = Offset(size.width / 2, size.height / 2); - canvas.drawCircle(center, 15, paint); - canvas.drawLine( - Offset(center.dx - 54, center.dy), - Offset(center.dx - 18, center.dy), - paint, - ); - canvas.drawLine( - Offset(center.dx + 18, center.dy), - Offset(center.dx + 54, center.dy), - paint, - ); - canvas.drawLine( - Offset(center.dx, center.dy - 9), - Offset(center.dx, center.dy + 9), - paint, - ); - - final textPainter = TextPainter(textDirection: TextDirection.ltr); - for (var index = -3; index <= 3; index++) { - if (index == 0) { - continue; - } - final y = center.dy - index * 24; - final width = 18 + index.abs() * 7.0; - canvas.drawLine( - Offset(center.dx - width, y), - Offset(center.dx + width, y), - paint, - ); - textPainter.text = TextSpan( - text: '${index * 10}', - style: GoogleFonts.robotoMono(color: accentMint, fontSize: 8), - ); - textPainter.layout(); - textPainter.paint(canvas, Offset(center.dx + width + 5, y - 5)); - } - - final scalePaint = paint..color = accentMint.withValues(alpha: .62); - final left = 70.0; - final right = size.width - 70; - for (var index = 0; index < 7; index++) { - final y = 62 + index * ((size.height - 124) / 6); - canvas.drawLine(Offset(left - 8, y), Offset(left + 8, y), scalePaint); - canvas.drawLine(Offset(right - 8, y), Offset(right + 8, y), scalePaint); - } - } - - @override - bool shouldRepaint(covariant _FpvHudPainter oldDelegate) => false; -} diff --git a/ground-suite/lib/features/command/presentation/tactical_controller.dart b/ground-suite/lib/features/command/presentation/tactical_controller.dart deleted file mode 100644 index ce8cd43..0000000 --- a/ground-suite/lib/features/command/presentation/tactical_controller.dart +++ /dev/null @@ -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 keyboardPoints = const [], - }) : keyboardPoints = List.unmodifiable(keyboardPoints); - - final double hue; - final double saturation; - final double brightness; - final double thickness; - final List 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 points, - }) : points = List.unmodifiable(points); - - final String id; - final double hue; - final double saturation; - final double brightness; - final double thickness; - final List 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 selectedUnitIds; - final TacticalMode mode; - final TacticalPanel? panel; - final UnitCreationDraft? unitDraft; - final List drawings; - final List markers; - final String? selectedDrawingId; - final String? selectedMarkerId; - final Set overlays; - - TacticalUiState copyWith({ - Set? selectedUnitIds, - TacticalMode? mode, - TacticalPanel? panel, - bool clearPanel = false, - UnitCreationDraft? unitDraft, - bool clearUnitDraft = false, - List? drawings, - List? markers, - String? selectedDrawingId, - String? selectedMarkerId, - bool clearSelectedDrawing = false, - bool clearSelectedMarker = false, - Set? 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.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 { - int _nextDrawing = 1; - int _nextMarker = 1; - - @override - TacticalUiState build() => TacticalUiState.initial(); - - void selectUnit(String unitId, {bool toggle = false}) { - final selected = toggle ? {...state.selectedUnitIds} : {}; - 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 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 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 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 _decimateStroke(List 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 stroke = const []}) - : stroke = List.unmodifiable(stroke); - - final LocalScenePoint? point; - final List stroke; -} - -final tacticalPointerPreviewProvider = - NotifierProvider( - TacticalPointerPreviewController.new, - ); - -class TacticalPointerPreviewController - extends Notifier { - @override - TacticalPointerPreview build() => TacticalPointerPreview(); - - void show(LocalScenePoint point, {List 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(); -} diff --git a/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart b/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart deleted file mode 100644 index b7a1df9..0000000 --- a/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart +++ /dev/null @@ -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 createState() => _DiagnosticsPageState(); -} - -class _DiagnosticsPageState extends ConsumerState { - _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 events) { - final count = switch (filter) { - _DiagnosticFilter.all => events.length, - _DiagnosticFilter.logs => events.whereType().length, - _DiagnosticFilter.errors => events.whereType().length, - _DiagnosticFilter.shutdowns => events.whereType().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 = [ - 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 = [ - if (additionalInformation.isNotEmpty) additionalInformation, - ?flagDetail, - ]; - return details.isEmpty ? null : details.join('\n'); -} - -String? _formatErrorFlags(int flags) { - final labels = [ - 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'; diff --git a/ground-suite/lib/features/fleet/domain/fleet_unit.dart b/ground-suite/lib/features/fleet/domain/fleet_unit.dart deleted file mode 100644 index f0d75e5..0000000 --- a/ground-suite/lib/features/fleet/domain/fleet_unit.dart +++ /dev/null @@ -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); -} diff --git a/ground-suite/lib/features/observability/presentation/observability_controller.dart b/ground-suite/lib/features/observability/presentation/observability_controller.dart deleted file mode 100644 index 50133c2..0000000 --- a/ground-suite/lib/features/observability/presentation/observability_controller.dart +++ /dev/null @@ -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((ref) { - final port = const ReplayVehicleDataPort(); - ref.onDispose(() => unawaited(port.close())); - return port; -}); - -final observabilityControllerProvider = - NotifierProvider( - ObservabilityController.new, - ); - -class ObservabilityController extends Notifier { - static const _criticalAnnouncementWindow = Duration(seconds: 2); - static const _timelineCapacity = 500; - - StreamSubscription? _subscription; - DateTime? _lastCriticalAnnouncementAt; - - @override - ObservabilityState build() { - final source = ref.read(vehicleDataPortProvider); - ref.onDispose(_cancelSubscription); - Future.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 current, - ObservedEvent incoming, - ) { - if (current.length < _timelineCapacity) { - return _TimelineRetention([...current, incoming]); - } - - final retained = List.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 events, {this.evictionCount = 0}) - : events = List.unmodifiable(events); - - final List 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 events; - final HeartbeatEvent? lastHeartbeat; - final Set observedTelemetryKinds; - final int parserDropCount; - final int semanticDropCount; - final int coalescedAlertCount; - final int localTimelineEvictionCount; - final DateTime? lastFrameAt; - final String? accessibilityAnnouncement; - - ObservabilityState copyWith({ - ObservationStatus? status, - List? events, - HeartbeatEvent? lastHeartbeat, - Set? 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; -} diff --git a/ground-suite/lib/features/vehicle_workspace/domain/vehicle_catalog.dart b/ground-suite/lib/features/vehicle_workspace/domain/vehicle_catalog.dart deleted file mode 100644 index cff1411..0000000 --- a/ground-suite/lib/features/vehicle_workspace/domain/vehicle_catalog.dart +++ /dev/null @@ -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( - id: 'kaptan.vehicle.quadcopter.local', - name: 'Quadcopter', - vehicleClass: 'Multirotor', - supportedProfile: 'Local scene unit', - media: VehicleCatalogMedia( - imageAssetPath: 'assets/vehicle_catalog/photos/quadcopter.png', - ), - ), - VehicleDefinition( - id: 'kaptan.vehicle.fixed-wing.local', - name: 'Fixed-wing', - vehicleClass: 'Fixed-wing', - supportedProfile: 'Local scene unit', - media: VehicleCatalogMedia( - imageAssetPath: 'assets/vehicle_catalog/photos/fixed_wing.png', - ), - ), - VehicleDefinition( - id: 'kaptan.vehicle.tiltrotor-vtol.local', - name: 'Tiltrotor VTOL', - vehicleClass: 'VTOL', - supportedProfile: 'Local scene unit', - media: VehicleCatalogMedia( - imageAssetPath: 'assets/vehicle_catalog/photos/tiltrotor_vtol.png', - ), - ), - VehicleDefinition( - id: 'kaptan.vehicle.helicopter.local', - name: 'Helicopter', - vehicleClass: 'Rotorcraft', - supportedProfile: 'Local scene unit', - media: VehicleCatalogMedia( - imageAssetPath: 'assets/vehicle_catalog/photos/helicopter.png', - ), - ), - VehicleDefinition( - id: 'kaptan.vehicle.rover.local', - name: 'Ground rover', - vehicleClass: 'Ground vehicle', - supportedProfile: 'Local scene unit', - media: VehicleCatalogMedia( - imageAssetPath: 'assets/vehicle_catalog/photos/rover.png', - ), - ), - VehicleDefinition( - id: 'kaptan.vehicle.surface-vessel.local', - name: 'Surface vessel', - vehicleClass: 'Marine vehicle', - supportedProfile: 'Local scene unit', - media: VehicleCatalogMedia( - imageAssetPath: 'assets/vehicle_catalog/photos/surface_vessel.png', - ), - ), -]; diff --git a/ground-suite/lib/features/vehicle_workspace/domain/vehicle_definition.dart b/ground-suite/lib/features/vehicle_workspace/domain/vehicle_definition.dart deleted file mode 100644 index 19a8428..0000000 --- a/ground-suite/lib/features/vehicle_workspace/domain/vehicle_definition.dart +++ /dev/null @@ -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; -} diff --git a/ground-suite/lib/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart b/ground-suite/lib/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart deleted file mode 100644 index c44b15c..0000000 --- a/ground-suite/lib/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart +++ /dev/null @@ -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.new, - ); - -/// Local fleet workspace state. -/// -/// TODO(gcs-fleet-storage): persist local-unit IDs and reviewed definition IDs -/// in a versioned workspace store. -class VehicleWorkspaceState { - const VehicleWorkspaceState({required this.units}); - - const VehicleWorkspaceState.unselected() : units = const []; - - final List units; - - bool get hasUnits => units.isNotEmpty; - - VehicleWorkspaceState copyWith({List? units}) => - VehicleWorkspaceState(units: units ?? this.units); -} - -class VehicleWorkspaceController extends Notifier { - static const maximumLocalUnits = 64; - static const _minimumSeparationSquared = .0016; - - @override - VehicleWorkspaceState build() => const VehicleWorkspaceState.unselected(); - - /// Validation shared by the hangar draft and committed workspace updates. - /// It applies only to the operator-local display label, never a vehicle ID. - static bool isValidLocalDisplayName(String value) => - _normalizeDisplayName(value) != null; - - bool canPlace(LocalScenePoint localPlacement) => - state.units.length < maximumLocalUnits && - state.units.every((unit) { - final dx = unit.localPlacement.x - localPlacement.x; - final dy = unit.localPlacement.y - localPlacement.y; - return dx * dx + dy * dy >= _minimumSeparationSquared; - }); - - bool addUnit({ - required VehicleDefinition definition, - required LocalScenePoint localPlacement, - String? displayName, - }) { - if (!canPlace(localPlacement)) { - return false; - } - final sequence = state.units.length + 1; - final unit = FleetUnit( - localUnitId: 'local-unit-$sequence', - displayName: - _normalizeDisplayName(displayName) ?? - '${definition.name.toUpperCase()}-${sequence.toString().padLeft(2, '0')}', - definition: definition, - localPlacement: localPlacement, - ); - state = VehicleWorkspaceState(units: [...state.units, unit]); - return true; - } - - /// Creates a local unit at a deterministic, scattered scene position. - /// - /// Until a reviewed MAVLink position binding exists, this is presentation - /// placement only: it is neither a GPS position nor received vehicle data. - bool addUnitAutomatically({ - required VehicleDefinition definition, - String? displayName, - }) { - for (var attempt = 0; attempt < maximumLocalUnits; attempt++) { - final sequence = state.units.length + attempt + 1; - final placement = LocalScenePoint( - x: .14 + _fraction(sequence * .61803398875) * .72, - y: .16 + _fraction(sequence * .41421356237) * .66, - ); - if (addUnit( - definition: definition, - localPlacement: placement, - displayName: displayName, - )) { - return true; - } - } - return false; - } - - static double _fraction(double value) => value - value.floorToDouble(); - - bool renameUnit(String localUnitId, String displayName) { - final normalized = _normalizeDisplayName(displayName); - if (normalized == null) { - return false; - } - var changed = false; - final units = state.units - .map((unit) { - if (unit.localUnitId != localUnitId) { - return unit; - } - changed = true; - return unit.copyWith(displayName: normalized); - }) - .toList(growable: false); - if (!changed) { - return false; - } - state = state.copyWith(units: units); - return true; - } - - static String? _normalizeDisplayName(String? value) { - final normalized = value?.trim(); - if (normalized == null || - normalized.isEmpty || - normalized.runes.length > 32) { - return null; - } - return normalized; - } -} diff --git a/ground-suite/lib/features/vehicle_workspace/presentation/widgets/vehicle_catalog_card.dart b/ground-suite/lib/features/vehicle_workspace/presentation/widgets/vehicle_catalog_card.dart deleted file mode 100644 index ddf634f..0000000 --- a/ground-suite/lib/features/vehicle_workspace/presentation/widgets/vehicle_catalog_card.dart +++ /dev/null @@ -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 createState() => _VehicleCatalogCardState(); -} - -class _VehicleCatalogCardState extends State { - bool _showFocus = false; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final accent = widget.selected || _showFocus ? scheme.primary : null; - - return Semantics( - button: true, - checked: widget.selected, - inMutuallyExclusiveGroup: true, - label: - '${widget.definition.name}, ${widget.definition.vehicleClass}, ' - '${widget.definition.supportedProfile}, local unit type, ' - 'local vehicle image, not telemetry', - onTap: widget.onSelected, - child: DecoratedBox( - decoration: BoxDecoration( - border: _showFocus - ? Border.all(color: scheme.primary, width: 2) - : null, - borderRadius: BorderRadius.circular(14), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - excludeFromSemantics: true, - onFocusChange: (value) => setState(() => _showFocus = value), - onTap: widget.onSelected, - borderRadius: BorderRadius.circular(12), - child: InstrumentPanel( - accent: accent, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _VehicleImage(media: widget.definition.media), - Padding( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ConsoleLabel('Local unit type'), - const SizedBox(height: 6), - Text( - widget.definition.name, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 2), - Text( - '${widget.definition.vehicleClass} · ${widget.definition.supportedProfile}', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 10), - Row( - children: [ - Icon( - widget.selected - ? Icons.radio_button_checked - : Icons.radio_button_off, - size: 18, - color: widget.selected - ? scheme.primary - : scheme.onSurfaceVariant, - ), - const SizedBox(width: 7), - Expanded( - child: Text( - widget.selected - ? 'Selected for workspace' - : 'Select for workspace', - style: Theme.of(context).textTheme.labelLarge, - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _VehicleImage extends StatelessWidget { - const _VehicleImage({required this.media}); - - final VehicleCatalogMedia media; - - @override - Widget build(BuildContext context) => ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(11)), - child: AspectRatio( - aspectRatio: 16 / 10, - child: Image.asset( - media.imageAssetPath, - key: Key('vehicle-image-${media.imageAssetPath}'), - fit: BoxFit.cover, - filterQuality: FilterQuality.high, - excludeFromSemantics: true, - errorBuilder: (context, error, stackTrace) => ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const Center(child: Icon(Icons.image_not_supported_outlined)), - ), - ), - ), - ); -} diff --git a/ground-suite/lib/main.dart b/ground-suite/lib/main.dart deleted file mode 100644 index bd40337..0000000 --- a/ground-suite/lib/main.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import 'app/app.dart'; - -void main() { - runApp(const GroundSuiteApp()); -} diff --git a/ground-suite/lib/shared/widgets/severity_badge.dart b/ground-suite/lib/shared/widgets/severity_badge.dart deleted file mode 100644 index b3559b7..0000000 --- a/ground-suite/lib/shared/widgets/severity_badge.dart +++ /dev/null @@ -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), - ); - } -} diff --git a/ground-suite/lib/shared/widgets/surfaces.dart b/ground-suite/lib/shared/widgets/surfaces.dart deleted file mode 100644 index fb603ef..0000000 --- a/ground-suite/lib/shared/widgets/surfaces.dart +++ /dev/null @@ -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), - ), - ); - } -} diff --git a/ground-suite/linux/.gitignore b/ground-suite/linux/.gitignore deleted file mode 100644 index d3896c9..0000000 --- a/ground-suite/linux/.gitignore +++ /dev/null @@ -1 +0,0 @@ -flutter/ephemeral diff --git a/ground-suite/linux/CMakeLists.txt b/ground-suite/linux/CMakeLists.txt deleted file mode 100644 index e06702e..0000000 --- a/ground-suite/linux/CMakeLists.txt +++ /dev/null @@ -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 "$<$>:-O3>") - target_compile_definitions(${TARGET} PRIVATE "$<$>: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() diff --git a/ground-suite/linux/flutter/CMakeLists.txt b/ground-suite/linux/flutter/CMakeLists.txt deleted file mode 100644 index d5bd016..0000000 --- a/ground-suite/linux/flutter/CMakeLists.txt +++ /dev/null @@ -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} -) diff --git a/ground-suite/linux/flutter/generated_plugin_registrant.cc b/ground-suite/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index e71a16d..0000000 --- a/ground-suite/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,11 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - - -void fl_register_plugins(FlPluginRegistry* registry) { -} diff --git a/ground-suite/linux/flutter/generated_plugin_registrant.h b/ground-suite/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47..0000000 --- a/ground-suite/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/ground-suite/linux/flutter/generated_plugins.cmake b/ground-suite/linux/flutter/generated_plugins.cmake deleted file mode 100644 index be1ee3e..0000000 --- a/ground-suite/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -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 $) - 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) diff --git a/ground-suite/linux/runner/CMakeLists.txt b/ground-suite/linux/runner/CMakeLists.txt deleted file mode 100644 index e97dabc..0000000 --- a/ground-suite/linux/runner/CMakeLists.txt +++ /dev/null @@ -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}") diff --git a/ground-suite/linux/runner/main.cc b/ground-suite/linux/runner/main.cc deleted file mode 100644 index e7c5c54..0000000 --- a/ground-suite/linux/runner/main.cc +++ /dev/null @@ -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); -} diff --git a/ground-suite/linux/runner/my_application.cc b/ground-suite/linux/runner/my_application.cc deleted file mode 100644 index 2c93a65..0000000 --- a/ground-suite/linux/runner/my_application.cc +++ /dev/null @@ -1,148 +0,0 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#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)); -} diff --git a/ground-suite/linux/runner/my_application.h b/ground-suite/linux/runner/my_application.h deleted file mode 100644 index db16367..0000000 --- a/ground-suite/linux/runner/my_application.h +++ /dev/null @@ -1,21 +0,0 @@ -#ifndef FLUTTER_MY_APPLICATION_H_ -#define FLUTTER_MY_APPLICATION_H_ - -#include - -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_ diff --git a/ground-suite/pubspec.lock b/ground-suite/pubspec.lock deleted file mode 100644 index 1f52fa8..0000000 --- a/ground-suite/pubspec.lock +++ /dev/null @@ -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" diff --git a/ground-suite/pubspec.yaml b/ground-suite/pubspec.yaml deleted file mode 100644 index fc5fea5..0000000 --- a/ground-suite/pubspec.yaml +++ /dev/null @@ -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 diff --git a/ground-suite/test/core/transport/replay_vehicle_data_port_test.dart b/ground-suite/test/core/transport/replay_vehicle_data_port_test.dart deleted file mode 100644 index 4cf6318..0000000 --- a/ground-suite/test/core/transport/replay_vehicle_data_port_test.dart +++ /dev/null @@ -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(), hasLength(1)); - expect(events.whereType(), hasLength(2)); - final logs = events.whereType().toList(); - final errors = events.whereType().toList(); - expect(logs, hasLength(2)); - expect(errors, hasLength(1)); - expect(events.whereType(), 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)'); - }); -} diff --git a/ground-suite/test/features/command/tactical_controller_test.dart b/ground-suite/test/features/command/tactical_controller_test.dart deleted file mode 100644 index fe33b6e..0000000 --- a/ground-suite/test/features/command/tactical_controller_test.dart +++ /dev/null @@ -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(), - ); - 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( - 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()); - 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()); - 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(), - ); - - controller.beginPencil(); - expect( - scope.read(tacticalControllerProvider).mode, - isA(), - ); - 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()); - }); - - 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()); - 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(), - ); - }, - ); -} diff --git a/ground-suite/test/features/observability/observability_controller_test.dart b/ground-suite/test/features/observability/observability_controller_test.dart deleted file mode 100644 index 964211f..0000000 --- a/ground-suite/test/features/observability/observability_controller_test.dart +++ /dev/null @@ -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.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.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.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.delayed(Duration.zero); - - port.events.add( - const ObservationDropped(kind: ObservationDropKind.parser, count: 2), - ); - port.events.add( - const ObservationDropped(kind: ObservationDropKind.semantic), - ); - await Future.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.delayed(Duration.zero); - - port.events.addError(StateError('serial endpoint closed')); - await Future.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.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(), hasLength(1)); - expect(state.lastHeartbeat?.systemId, 7); - expect(state.observedTelemetryKinds, contains(TelemetryKind.rawImu)); - }, - ); -} - -class _ControlledDataPort implements VehicleDataPort { - final events = StreamController(sync: true); - - @override - bool get isReplay => false; - - @override - String get label => 'Test transport'; - - @override - Future close() => events.close(); - - @override - Stream observe() => events.stream; -} diff --git a/ground-suite/test/features/vehicle_workspace/vehicle_catalog_test.dart b/ground-suite/test/features/vehicle_workspace/vehicle_catalog_test.dart deleted file mode 100644 index 1c1e803..0000000 --- a/ground-suite/test/features/vehicle_workspace/vehicle_catalog_test.dart +++ /dev/null @@ -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([ - 'Quadcopter', - 'Fixed-wing', - 'Tiltrotor VTOL', - 'Helicopter', - 'Ground rover', - 'Surface vessel', - ]), - ); - for (final definition in supportedVehicleDefinitions) { - expect(definition.id, isNotEmpty); - expect(definition.name, isNotEmpty); - expect(definition.media.imageAssetPath, isNotEmpty); - } - }); - - testWidgets('catalog card is a single keyboard-activatable radio control', ( - tester, - ) async { - var selections = 0; - final definition = supportedVehicleDefinitions.first; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SingleChildScrollView( - child: Center( - child: SizedBox( - width: 760, - child: VehicleCatalogCard( - definition: definition, - selected: false, - onSelected: () => selections += 1, - ), - ), - ), - ), - ), - ), - ); - - final card = find.byType(VehicleCatalogCard); - final semantics = tester.getSemantics(card); - expect(semantics.flagsCollection.isChecked, CheckedState.isFalse); - expect(semantics.flagsCollection.isInMutuallyExclusiveGroup, isTrue); - expect(semantics.label, contains('local vehicle image, not telemetry')); - - await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.space); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pump(); - - expect(selections, 2); - }); - - testWidgets('definition images are bundled locally', (tester) async { - for (final definition in supportedVehicleDefinitions) { - final image = await rootBundle.load(definition.media.imageAssetPath); - expect(image.lengthInBytes, greaterThan(0)); - } - }); -} diff --git a/ground-suite/test/features/vehicle_workspace/vehicle_workspace_controller_test.dart b/ground-suite/test/features/vehicle_workspace/vehicle_workspace_controller_test.dart deleted file mode 100644 index f709187..0000000 --- a/ground-suite/test/features/vehicle_workspace/vehicle_workspace_controller_test.dart +++ /dev/null @@ -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', - ); - }, - ); -} diff --git a/ground-suite/test/widget_test.dart b/ground-suite/test/widget_test.dart deleted file mode 100644 index de4b165..0000000 --- a/ground-suite/test/widget_test.dart +++ /dev/null @@ -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(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(), - ); - 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(find.byType(MaterialApp)).themeMode, - ThemeMode.dark, - ); - expect(Theme.of(context).brightness, Brightness.dark); - expect(Theme.of(context).cardColor, panelBackground); - expect( - tester - .widget( - 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(), - ); - }); - - 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(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); - }); -}