feat(ground-suite): add replay observability console
This commit is contained in:
1 parent
1d28db2983
commit
c06c293f49
33 files changed
+2636
No files matched your search
@@ -0,0 +1,45 @@
|
||||
# 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
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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'
|
||||
@@ -0,0 +1,61 @@
|
||||
# Ground Suite
|
||||
|
||||
Ground Suite is Kaptan's desktop ground-control software. Its application
|
||||
identifier is `com.orkavian.ground_suite`.
|
||||
|
||||
## Current scope
|
||||
|
||||
The first vertical slice is a **read-only observability console**. It uses a
|
||||
deterministic replay fixture to present a vehicle identity, heartbeat state,
|
||||
telemetry presence, ordinary logs, runtime errors, and orderly-shutdown
|
||||
observations. It never sends bytes to a vehicle.
|
||||
|
||||
This is deliberate: the current F722 firmware rejects every inbound MAVLink
|
||||
frame, and `kaptan-sitl serve` has no MAVLink endpoint. A connected-looking UI
|
||||
must not suggest the vehicle is controllable, safe, armed, or writable.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
VehicleDataPort (replay now; MAVLink adapter later)
|
||||
-> typed observation-or-drop updates
|
||||
-> Riverpod observability state
|
||||
-> Overview / Diagnostics presentation
|
||||
```
|
||||
|
||||
`MavlinkByteSource` and `MavlinkEventDecoder` are receive-only seams. The
|
||||
future decoder must combine pinned MAVLink Common definitions with Kaptan's
|
||||
`kaptan.xml` root, and preserve the firmware's semantic rules for bounded
|
||||
`KAPTAN_LOG`, `KAPTAN_ERROR`, and `KAPTAN_SHUTDOWN` payloads. Raw severity,
|
||||
shutdown-reason, and diagnostic-flag values remain visible even when an app
|
||||
does not yet recognise them. Expected malformed or unsupported frames become
|
||||
parser/semantic drop updates and never terminate the observation stream.
|
||||
|
||||
## 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'
|
||||
```
|
||||
|
||||
Linux is the first tested desktop runner. Native macOS and Windows runners
|
||||
will be added only with their respective CI coverage.
|
||||
|
||||
## Deliberately deferred work
|
||||
|
||||
- TODO(gcs-live-mavlink): generated MAVLink Common plus custom-dialect
|
||||
bindings, a MAVLink 2 receive parser, semantic validation, and
|
||||
serial/UDP/recording endpoints.
|
||||
- TODO(gcs-connection): endpoint lifecycle, reconnect generation, link-loss
|
||||
policy, and parser/semantic-drop accounting sourced from real frames.
|
||||
- TODO(gcs-config): descriptor-driven configuration UI only after firmware has
|
||||
authenticated ingress, source binding, persistent anti-replay state, target
|
||||
storage, and a disarmed atomic activation path.
|
||||
- TODO(gcs-upload): CBOR import/export only through the reviewed transaction
|
||||
protocol; never treat a local file as active vehicle configuration.
|
||||
- TODO(gcs-control): arm, command, calibration, and shutdown workflows need
|
||||
their own reviewed authority and safety contracts.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_shell.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
class GroundSuiteApp extends StatelessWidget {
|
||||
const GroundSuiteApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Ground Suite',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: groundSuiteTheme(),
|
||||
home: const AppShell(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../features/diagnostics/presentation/diagnostics_page.dart';
|
||||
import '../features/observability/presentation/observability_controller.dart';
|
||||
import '../features/overview/presentation/overview_page.dart';
|
||||
|
||||
enum _Destination { overview, diagnostics }
|
||||
|
||||
class AppShell extends ConsumerStatefulWidget {
|
||||
const AppShell({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends ConsumerState<AppShell> {
|
||||
_Destination _destination = _Destination.overview;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(observabilityControllerProvider);
|
||||
const pages = <Widget>[OverviewPage(), DiagnosticsPage()];
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final compact = constraints.maxWidth < 760;
|
||||
final content = _Workspace(
|
||||
state: state,
|
||||
selectedIndex: _destination.index,
|
||||
pages: pages,
|
||||
compact: compact,
|
||||
);
|
||||
return compact
|
||||
? _CompactShell(
|
||||
selectedIndex: _destination.index,
|
||||
onSelected: _selectDestination,
|
||||
content: content,
|
||||
)
|
||||
: _WideShell(
|
||||
selectedIndex: _destination.index,
|
||||
onSelected: _selectDestination,
|
||||
content: content,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (state.accessibilityAnnouncement case final announcement?)
|
||||
_CriticalAnnouncement(message: announcement),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _selectDestination(int index) {
|
||||
setState(() => _destination = _Destination.values[index]);
|
||||
}
|
||||
}
|
||||
|
||||
class _WideShell extends StatelessWidget {
|
||||
const _WideShell({
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
required this.content,
|
||||
});
|
||||
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
final Widget content;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
selectedIndex: selectedIndex,
|
||||
labelType: NavigationRailLabelType.all,
|
||||
onDestinationSelected: onSelected,
|
||||
leading: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Icon(Icons.flight_takeoff_outlined, size: 28),
|
||||
),
|
||||
destinations: _destinations,
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: content),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompactShell extends StatelessWidget {
|
||||
const _CompactShell({
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
required this.content,
|
||||
});
|
||||
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
final Widget content;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(child: content),
|
||||
NavigationBar(
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onSelected,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.space_dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.space_dashboard),
|
||||
label: 'Overview',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.receipt_long_outlined),
|
||||
selectedIcon: Icon(Icons.receipt_long),
|
||||
label: 'Diagnostics',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _destinations = <NavigationRailDestination>[
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.space_dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.space_dashboard),
|
||||
label: Text('Overview'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.receipt_long_outlined),
|
||||
selectedIcon: Icon(Icons.receipt_long),
|
||||
label: Text('Diagnostics'),
|
||||
),
|
||||
];
|
||||
|
||||
class _Workspace extends StatelessWidget {
|
||||
const _Workspace({
|
||||
required this.state,
|
||||
required this.selectedIndex,
|
||||
required this.pages,
|
||||
required this.compact,
|
||||
});
|
||||
|
||||
final ObservabilityState state;
|
||||
final int selectedIndex;
|
||||
final List<Widget> pages;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
_ConnectionBar(state: state, compact: compact),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: IndexedStack(index: selectedIndex, children: pages),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectionBar extends StatelessWidget {
|
||||
const _ConnectionBar({required this.state, required this.compact});
|
||||
|
||||
final ObservabilityState state;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final heartbeat = state.lastHeartbeat;
|
||||
final peer = heartbeat == null
|
||||
? 'No vehicle identity observed'
|
||||
: 'System ${heartbeat.systemId} / component ${heartbeat.componentId}';
|
||||
final frameTime = state.lastFrameAt == null
|
||||
? 'No frames received'
|
||||
: 'Last frame ${_formatTimestamp(state.lastFrameAt!)}';
|
||||
|
||||
return Semantics(
|
||||
label: '${state.status.label}; ${state.sourceLabel}; $peer; $frameTime',
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 16 : 24,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.visibility_outlined, color: theme.colorScheme.primary),
|
||||
Text('Observation only', style: theme.textTheme.titleSmall),
|
||||
_StatusChip(label: state.sourceLabel),
|
||||
_StatusChip(label: state.status.label),
|
||||
Text(peer),
|
||||
Text(frameTime),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CriticalAnnouncement extends StatelessWidget {
|
||||
const _CriticalAnnouncement({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
container: true,
|
||||
liveRegion: true,
|
||||
label: message,
|
||||
child: const SizedBox.square(dimension: 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
const _StatusChip({required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Chip(label: Text(label), visualDensity: VisualDensity.compact);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime value) => value.toIso8601String();
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
ThemeData groundSuiteTheme() {
|
||||
const seed = Color(0xff006c8f);
|
||||
final scheme = ColorScheme.fromSeed(seedColor: seed);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
visualDensity: VisualDensity.standard,
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Receive-only bytes from one MAVLink transport endpoint.
|
||||
///
|
||||
/// A source has one reader and owns its close lifecycle. A future adapter will
|
||||
/// bind serial, UDP, or a recording source here; no source in the first slice
|
||||
/// may write bytes to a vehicle.
|
||||
abstract interface class MavlinkByteSource {
|
||||
Stream<Uint8List> get chunks;
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import '../observations/vehicle_data_update.dart';
|
||||
import 'mavlink_byte_source.dart';
|
||||
|
||||
/// Converts a receive-only MAVLink byte stream into semantically safe events.
|
||||
///
|
||||
/// TODO(gcs-live-mavlink): generate this adapter from pinned MAVLink Common
|
||||
/// definitions plus Kaptan's `kaptan.xml` root. It must reject MAVLink 1 and
|
||||
/// CRC-invalid MAVLink 2 frames, validate every fixed diagnostic length before
|
||||
/// UTF-8 decoding, retain unknown enum values and flags, and emit parser or
|
||||
/// semantic drop updates without ending the connection.
|
||||
abstract interface class MavlinkEventDecoder {
|
||||
Stream<VehicleDataUpdate> decode(MavlinkByteSource source);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/// 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;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'vehicle_data_update.dart';
|
||||
|
||||
/// A receive-only source of already-classified vehicle observations.
|
||||
///
|
||||
/// The replay implementation is the first concrete port. A live port may be
|
||||
/// added only after the custom MAVLink decoder has its length/CRC/enum safety
|
||||
/// contract and endpoint lifecycle tests.
|
||||
abstract interface class VehicleDataPort {
|
||||
String get label;
|
||||
|
||||
bool get isReplay;
|
||||
|
||||
Stream<VehicleDataUpdate> observe();
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import '../observations/observed_event.dart';
|
||||
import '../observations/vehicle_data_port.dart';
|
||||
import '../observations/vehicle_data_update.dart';
|
||||
|
||||
/// Deterministic observations for the first Ground Suite workflow.
|
||||
///
|
||||
/// This is intentionally a typed replay rather than a pretend connected
|
||||
/// vehicle. Raw frame replay moves behind the MAVLink decoder once bindings
|
||||
/// are generated from the MAVLink Common and Kaptan dialect definitions.
|
||||
final class ReplayVehicleDataPort implements VehicleDataPort {
|
||||
const ReplayVehicleDataPort();
|
||||
|
||||
@override
|
||||
String get label => 'Replay fixture';
|
||||
|
||||
@override
|
||||
bool get isReplay => true;
|
||||
|
||||
@override
|
||||
Stream<VehicleDataUpdate> observe() => Stream<VehicleDataUpdate>.fromIterable(
|
||||
_events.map(ObservationReceived.new),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
|
||||
static final List<ObservedEvent> _events = [
|
||||
HeartbeatEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 12),
|
||||
systemId: 1,
|
||||
componentId: 1,
|
||||
armed: false,
|
||||
vehicleState: 'Standby',
|
||||
),
|
||||
TelemetryEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 1),
|
||||
kind: TelemetryKind.rawImu,
|
||||
),
|
||||
TelemetryEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 2),
|
||||
kind: TelemetryKind.distanceSensor,
|
||||
),
|
||||
LogEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 3),
|
||||
level: MavSeverity.warning,
|
||||
message: 'Range aid stale',
|
||||
flags: LogEvent.coalescedFlag,
|
||||
),
|
||||
ErrorEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 4),
|
||||
path: 'kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
|
||||
additionalInformation: 'The replay contains a bounded error detail.',
|
||||
flags: ErrorEvent.truncatedFlag,
|
||||
),
|
||||
LogEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 5),
|
||||
level: MavSeverity.info,
|
||||
message: 'Replay fixture complete',
|
||||
),
|
||||
ShutdownEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 6),
|
||||
reason: KaptanShutdownReason.operator,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
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 '../../observability/presentation/observability_controller.dart';
|
||||
|
||||
enum _DiagnosticFilter { all, logs, errors, shutdowns }
|
||||
|
||||
class DiagnosticsPage extends ConsumerStatefulWidget {
|
||||
const DiagnosticsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DiagnosticsPage> createState() => _DiagnosticsPageState();
|
||||
}
|
||||
|
||||
class _DiagnosticsPageState extends ConsumerState<DiagnosticsPage> {
|
||||
_DiagnosticFilter _filter = _DiagnosticFilter.all;
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(observabilityControllerProvider);
|
||||
final events = state.events.where(_matches).toList(growable: false);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
Text('Diagnostics', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Messages remain distinct by protocol class. A configuration result '
|
||||
'will remain authoritative when configuration support is added.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _DiagnosticFilter.values
|
||||
.map(
|
||||
(filter) => ChoiceChip(
|
||||
label: Text(_labelFor(filter)),
|
||||
selected: _filter == filter,
|
||||
onSelected: (_) => setState(() => _filter = filter),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Filter diagnostics',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() => _query = value.trim().toLowerCase());
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (events.isEmpty)
|
||||
const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: Text('No diagnostic observations match this filter.'),
|
||||
),
|
||||
)
|
||||
else
|
||||
...events.map((event) => _DiagnosticCard(event: event)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
bool _matches(ObservedEvent event) {
|
||||
final typeMatches = switch (_filter) {
|
||||
_DiagnosticFilter.all =>
|
||||
event is LogEvent || event is ErrorEvent || event is ShutdownEvent,
|
||||
_DiagnosticFilter.logs => event is LogEvent,
|
||||
_DiagnosticFilter.errors => event is ErrorEvent,
|
||||
_DiagnosticFilter.shutdowns => event is ShutdownEvent,
|
||||
};
|
||||
if (!typeMatches) {
|
||||
return false;
|
||||
}
|
||||
if (_query.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
return _searchableText(event).toLowerCase().contains(_query);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiagnosticCard extends StatelessWidget {
|
||||
const _DiagnosticCard({required this.event});
|
||||
|
||||
final ObservedEvent event;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final content = switch (event) {
|
||||
LogEvent(:final level, :final message, :final flags) => _LogContent(
|
||||
level: level,
|
||||
message: message,
|
||||
flags: flags,
|
||||
),
|
||||
ErrorEvent(:final path, :final additionalInformation, :final flags) =>
|
||||
_ErrorContent(
|
||||
path: path,
|
||||
additionalInformation: additionalInformation,
|
||||
flags: flags,
|
||||
),
|
||||
ShutdownEvent(:final reason) => _ShutdownContent(reason: reason),
|
||||
_ => null,
|
||||
};
|
||||
if (content == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
container: true,
|
||||
label: content.semanticLabel,
|
||||
child: Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
content.leading,
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: SelectionArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(content.title, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(content.detail),
|
||||
if (content.additionalDetail case final detail?) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(detail),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
event.receivedAt.toIso8601String(),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiagnosticContent {
|
||||
const _DiagnosticContent({
|
||||
required this.leading,
|
||||
required this.title,
|
||||
required this.detail,
|
||||
required this.semanticLabel,
|
||||
this.additionalDetail,
|
||||
});
|
||||
|
||||
final Widget leading;
|
||||
final String title;
|
||||
final String detail;
|
||||
final String? additionalDetail;
|
||||
final String semanticLabel;
|
||||
}
|
||||
|
||||
class _LogContent extends _DiagnosticContent {
|
||||
_LogContent({
|
||||
required MavSeverity level,
|
||||
required String message,
|
||||
required int flags,
|
||||
}) : super(
|
||||
leading: SeverityBadge(level: level),
|
||||
title: 'Log — ${level.label}',
|
||||
detail: message,
|
||||
additionalDetail: _formatLogFlags(flags),
|
||||
semanticLabel: _semanticLogLabel(level, message, flags),
|
||||
);
|
||||
}
|
||||
|
||||
class _ErrorContent extends _DiagnosticContent {
|
||||
_ErrorContent({
|
||||
required String path,
|
||||
required String additionalInformation,
|
||||
required int flags,
|
||||
}) : super(
|
||||
leading: const SeverityBadge(level: MavSeverity.error),
|
||||
title: 'Error',
|
||||
detail: path,
|
||||
additionalDetail: _formatErrorDetail(additionalInformation, flags),
|
||||
semanticLabel: _semanticErrorLabel(path, additionalInformation, flags),
|
||||
);
|
||||
}
|
||||
|
||||
class _ShutdownContent extends _DiagnosticContent {
|
||||
_ShutdownContent({required KaptanShutdownReason reason})
|
||||
: super(
|
||||
leading: const Icon(Icons.power_settings_new_outlined),
|
||||
title: 'Orderly shutdown observed',
|
||||
detail: 'Reason: ${reason.label}',
|
||||
semanticLabel: 'Orderly shutdown observed: ${reason.label}',
|
||||
);
|
||||
}
|
||||
|
||||
String _labelFor(_DiagnosticFilter filter) => switch (filter) {
|
||||
_DiagnosticFilter.all => 'All',
|
||||
_DiagnosticFilter.logs => 'Logs',
|
||||
_DiagnosticFilter.errors => 'Errors',
|
||||
_DiagnosticFilter.shutdowns => 'Shutdowns',
|
||||
};
|
||||
|
||||
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,
|
||||
_ => '',
|
||||
};
|
||||
|
||||
String? _formatLogFlags(int flags) {
|
||||
final labels = <String>[
|
||||
if (flags & LogEvent.traceFlag != 0) 'Trace',
|
||||
if (flags & LogEvent.truncatedFlag != 0) 'Truncated',
|
||||
if (flags & LogEvent.coalescedFlag != 0) 'Coalesced',
|
||||
if (flags & ~LogEvent.knownFlags != 0)
|
||||
'Unknown bits 0x${(flags & ~LogEvent.knownFlags).toRadixString(16)}',
|
||||
];
|
||||
return labels.isEmpty ? null : 'Flags: ${labels.join(', ')}';
|
||||
}
|
||||
|
||||
String? _formatErrorDetail(String additionalInformation, int flags) {
|
||||
final flagDetail = _formatErrorFlags(flags);
|
||||
final details = <String>[
|
||||
if (additionalInformation.isNotEmpty) additionalInformation,
|
||||
?flagDetail,
|
||||
];
|
||||
return details.isEmpty ? null : details.join('\n');
|
||||
}
|
||||
|
||||
String? _formatErrorFlags(int flags) {
|
||||
final labels = <String>[
|
||||
if (flags & ErrorEvent.truncatedFlag != 0) 'Truncated',
|
||||
if (flags & ErrorEvent.coalescedFlag != 0) 'Coalesced',
|
||||
if (flags & ~ErrorEvent.knownFlags != 0)
|
||||
'Unknown bits 0x${(flags & ~ErrorEvent.knownFlags).toRadixString(16)}',
|
||||
];
|
||||
return labels.isEmpty ? null : 'Flags: ${labels.join(', ')}';
|
||||
}
|
||||
|
||||
String _semanticLogLabel(MavSeverity level, String message, int flags) {
|
||||
final flagDetail = _formatLogFlags(flags);
|
||||
return flagDetail == null
|
||||
? '${level.label} log: $message'
|
||||
: '${level.label} log: $message. $flagDetail';
|
||||
}
|
||||
|
||||
String _semanticErrorLabel(
|
||||
String path,
|
||||
String additionalInformation,
|
||||
int flags,
|
||||
) {
|
||||
final detail = _formatErrorDetail(additionalInformation, flags);
|
||||
return detail == null ? 'Error: $path' : 'Error: $path. $detail';
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/observations/observed_event.dart';
|
||||
import '../../../core/observations/vehicle_data_port.dart';
|
||||
import '../../../core/observations/vehicle_data_update.dart';
|
||||
import '../../../core/transport/replay_vehicle_data_port.dart';
|
||||
|
||||
final vehicleDataPortProvider = Provider<VehicleDataPort>((ref) {
|
||||
final port = const ReplayVehicleDataPort();
|
||||
ref.onDispose(() => unawaited(port.close()));
|
||||
return port;
|
||||
});
|
||||
|
||||
final observabilityControllerProvider =
|
||||
NotifierProvider<ObservabilityController, ObservabilityState>(
|
||||
ObservabilityController.new,
|
||||
);
|
||||
|
||||
class ObservabilityController extends Notifier<ObservabilityState> {
|
||||
static const _criticalAnnouncementWindow = Duration(seconds: 2);
|
||||
|
||||
StreamSubscription<VehicleDataUpdate>? _subscription;
|
||||
DateTime? _lastCriticalAnnouncementAt;
|
||||
|
||||
@override
|
||||
ObservabilityState build() {
|
||||
final source = ref.read(vehicleDataPortProvider);
|
||||
ref.onDispose(_cancelSubscription);
|
||||
Future<void>.microtask(() => _observe(source));
|
||||
return ObservabilityState.initial(source);
|
||||
}
|
||||
|
||||
void _observe(VehicleDataPort source) {
|
||||
_subscription = source.observe().listen(
|
||||
_handleUpdate,
|
||||
onError: (Object _, StackTrace _) {
|
||||
state = state.copyWith(
|
||||
status: ObservationStatus.disconnected,
|
||||
accessibilityAnnouncement: 'Observation connection closed.',
|
||||
replaceAccessibilityAnnouncement: true,
|
||||
);
|
||||
},
|
||||
onDone: () {
|
||||
state = state.copyWith(
|
||||
status: source.isReplay
|
||||
? ObservationStatus.replayComplete
|
||||
: ObservationStatus.disconnected,
|
||||
accessibilityAnnouncement: source.isReplay
|
||||
? 'Replay complete.'
|
||||
: 'Observation connection closed.',
|
||||
replaceAccessibilityAnnouncement: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleUpdate(VehicleDataUpdate update) {
|
||||
switch (update) {
|
||||
case ObservationReceived(:final event):
|
||||
_record(event);
|
||||
case ObservationDropped(:final kind, :final count):
|
||||
state = state.copyWith(
|
||||
status: ObservationStatus.receiving,
|
||||
parserDropCount:
|
||||
state.parserDropCount +
|
||||
(kind == ObservationDropKind.parser ? count : 0),
|
||||
semanticDropCount:
|
||||
state.semanticDropCount +
|
||||
(kind == ObservationDropKind.semantic ? count : 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _record(ObservedEvent event) {
|
||||
final announcement = _announcementFor(event);
|
||||
state = state.copyWith(
|
||||
status: ObservationStatus.receiving,
|
||||
lastFrameAt: event.receivedAt,
|
||||
events: [...state.events, event],
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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.parserDropCount,
|
||||
required this.semanticDropCount,
|
||||
required this.coalescedAlertCount,
|
||||
this.lastFrameAt,
|
||||
this.accessibilityAnnouncement,
|
||||
});
|
||||
|
||||
factory ObservabilityState.initial(VehicleDataPort source) {
|
||||
return ObservabilityState(
|
||||
sourceLabel: source.label,
|
||||
isReplay: source.isReplay,
|
||||
status: ObservationStatus.starting,
|
||||
events: const [],
|
||||
parserDropCount: 0,
|
||||
semanticDropCount: 0,
|
||||
coalescedAlertCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
final String sourceLabel;
|
||||
final bool isReplay;
|
||||
final ObservationStatus status;
|
||||
final List<ObservedEvent> events;
|
||||
final int parserDropCount;
|
||||
final int semanticDropCount;
|
||||
final int coalescedAlertCount;
|
||||
final DateTime? lastFrameAt;
|
||||
final String? accessibilityAnnouncement;
|
||||
|
||||
HeartbeatEvent? get lastHeartbeat {
|
||||
for (final event in events.reversed) {
|
||||
if (event case HeartbeatEvent()) {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Set<TelemetryKind> get observedTelemetryKinds => {
|
||||
for (final event in events)
|
||||
if (event case TelemetryEvent(:final kind)) kind,
|
||||
};
|
||||
|
||||
ObservabilityState copyWith({
|
||||
ObservationStatus? status,
|
||||
List<ObservedEvent>? events,
|
||||
int? parserDropCount,
|
||||
int? semanticDropCount,
|
||||
int? coalescedAlertCount,
|
||||
DateTime? lastFrameAt,
|
||||
String? accessibilityAnnouncement,
|
||||
bool replaceAccessibilityAnnouncement = false,
|
||||
}) {
|
||||
return ObservabilityState(
|
||||
sourceLabel: sourceLabel,
|
||||
isReplay: isReplay,
|
||||
status: status ?? this.status,
|
||||
events: events ?? this.events,
|
||||
parserDropCount: parserDropCount ?? this.parserDropCount,
|
||||
semanticDropCount: semanticDropCount ?? this.semanticDropCount,
|
||||
coalescedAlertCount: coalescedAlertCount ?? this.coalescedAlertCount,
|
||||
lastFrameAt: lastFrameAt ?? this.lastFrameAt,
|
||||
accessibilityAnnouncement: replaceAccessibilityAnnouncement
|
||||
? accessibilityAnnouncement
|
||||
: this.accessibilityAnnouncement,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnnouncementDecision {
|
||||
const _AnnouncementDecision({this.message, this.coalesced = false});
|
||||
|
||||
final String? message;
|
||||
final bool coalesced;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../observability/presentation/observability_controller.dart';
|
||||
|
||||
class OverviewPage extends ConsumerWidget {
|
||||
const OverviewPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(observabilityControllerProvider);
|
||||
final heartbeat = state.lastHeartbeat;
|
||||
final telemetry = state.observedTelemetryKinds
|
||||
.map((kind) => kind.label)
|
||||
.join(', ');
|
||||
final identity = heartbeat == null
|
||||
? 'Not observed'
|
||||
: 'System ${heartbeat.systemId} / component ${heartbeat.componentId}';
|
||||
final identityDetail = heartbeat == null
|
||||
? 'Awaiting HEARTBEAT.'
|
||||
: 'Reported by last HEARTBEAT: '
|
||||
'${heartbeat.armed ? 'Armed' : 'Disarmed'} · ${heartbeat.vehicleState}';
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final horizontalPadding = constraints.maxWidth < 600 ? 16.0 : 24.0;
|
||||
final cardWidth = constraints.maxWidth < 640
|
||||
? constraints.maxWidth - (horizontalPadding * 2)
|
||||
: 280.0;
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.all(horizontalPadding),
|
||||
children: [
|
||||
Text('Overview', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'This workspace is receive-only. A link or replay never implies a '
|
||||
'safe, armed, or command-capable vehicle.',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
_SummaryCard(
|
||||
title: 'Source',
|
||||
value: state.sourceLabel,
|
||||
width: cardWidth,
|
||||
detail: state.isReplay
|
||||
? 'Deterministic replay; no physical vehicle is connected.'
|
||||
: 'Receive-only transport.',
|
||||
icon: Icons.visibility_outlined,
|
||||
),
|
||||
_SummaryCard(
|
||||
title: 'Vehicle identity',
|
||||
value: identity,
|
||||
detail: identityDetail,
|
||||
width: cardWidth,
|
||||
icon: Icons.memory_outlined,
|
||||
),
|
||||
_SummaryCard(
|
||||
title: 'Observed streams',
|
||||
value: telemetry.isEmpty ? 'None' : telemetry,
|
||||
detail: state.isReplay
|
||||
? '${state.events.length} replayed observation(s).'
|
||||
: '${state.events.length} observation(s).',
|
||||
width: cardWidth,
|
||||
icon: Icons.sensors_outlined,
|
||||
),
|
||||
_SummaryCard(
|
||||
title: 'Decoder accounting',
|
||||
value:
|
||||
'${state.parserDropCount} parser / ${state.semanticDropCount} semantic',
|
||||
detail: 'Counters are zero in the typed replay fixture.',
|
||||
width: cardWidth,
|
||||
icon: Icons.rule_outlined,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Configuration is unavailable',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Kaptan firmware does not yet accept inbound configuration '
|
||||
'authority. Ground Suite will not show upload, commit, arm, '
|
||||
'or shutdown controls until the firmware exposes reviewed '
|
||||
'authenticated ingress and safe activation.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
const _SummaryCard({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.detail,
|
||||
required this.icon,
|
||||
required this.width,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String value;
|
||||
final String detail;
|
||||
final IconData icon;
|
||||
final double width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon),
|
||||
const SizedBox(height: 16),
|
||||
Text(title, style: Theme.of(context).textTheme.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(detail),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app/app.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: GroundSuiteApp()));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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 (icon, color) = switch (level.wireValue) {
|
||||
0 || 1 || 2 || 3 => (Icons.error_outline, Colors.red.shade700),
|
||||
4 => (Icons.warning_amber_outlined, Colors.orange.shade800),
|
||||
5 || 6 => (Icons.info_outline, Colors.blue.shade700),
|
||||
7 => (Icons.bug_report_outlined, Colors.grey.shade700),
|
||||
_ => (Icons.help_outline, Colors.grey.shade700),
|
||||
};
|
||||
|
||||
return Semantics(
|
||||
label: 'Severity: ${level.label}',
|
||||
child: Icon(icon, color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
flutter/ephemeral
|
||||
@@ -0,0 +1,128 @@
|
||||
# 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 "com.orkavian.ground_suite")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(SET CMP0063 NEW)
|
||||
|
||||
# Load bundled libraries from the lib/ directory relative to the binary.
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
|
||||
|
||||
# Root filesystem for cross-building.
|
||||
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
|
||||
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
endif()
|
||||
|
||||
# Define build configuration options.
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||
STRING "Flutter build mode" FORCE)
|
||||
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
||||
"Debug" "Profile" "Release")
|
||||
endif()
|
||||
|
||||
# Compilation settings that should be applied to most targets.
|
||||
#
|
||||
# Be cautious about adding new options here, as plugins use this function by
|
||||
# default. In most cases, you should add new options to specific targets instead
|
||||
# of modifying this function.
|
||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
||||
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
|
||||
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||
endfunction()
|
||||
|
||||
# Flutter library and tool build rules.
|
||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
|
||||
# Application build; see runner/CMakeLists.txt.
|
||||
add_subdirectory("runner")
|
||||
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
|
||||
# Only the install-generated bundle's copy of the executable will launch
|
||||
# correctly, since the resources must in the right relative locations. To avoid
|
||||
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
||||
# the default top-level location.
|
||||
set_target_properties(${BINARY_NAME}
|
||||
PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
|
||||
)
|
||||
|
||||
|
||||
# Generated plugin build rules, which manage building the plugins and adding
|
||||
# them to the application.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
|
||||
# === Installation ===
|
||||
# By default, "installing" just makes a relocatable bundle in the build
|
||||
# directory.
|
||||
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
|
||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
||||
endif()
|
||||
|
||||
# Start with a clean build bundle directory every time.
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
|
||||
" COMPONENT Runtime)
|
||||
|
||||
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
|
||||
|
||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
|
||||
install(FILES "${bundled_library}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endforeach(bundled_library)
|
||||
|
||||
# Copy the native assets provided by the build.dart from all packages.
|
||||
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
|
||||
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
# Fully re-copy the assets directory on each build to avoid having stale files
|
||||
# from a previous install.
|
||||
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||
" COMPONENT Runtime)
|
||||
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||
|
||||
# Install the AOT library on non-Debug builds only.
|
||||
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
@@ -0,0 +1,88 @@
|
||||
# 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}
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GENERATED_PLUGIN_REGISTRANT_
|
||||
#define GENERATED_PLUGIN_REGISTRANT_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
// Registers Flutter plugins.
|
||||
void fl_register_plugins(FlPluginRegistry* registry);
|
||||
|
||||
#endif // GENERATED_PLUGIN_REGISTRANT_
|
||||
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
foreach(plugin ${FLUTTER_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
|
||||
endforeach(plugin)
|
||||
|
||||
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
|
||||
endforeach(ffi_plugin)
|
||||
@@ -0,0 +1,26 @@
|
||||
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}")
|
||||
@@ -0,0 +1,6 @@
|
||||
#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);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
#include "my_application.h"
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
#include <gdk/gdkx.h>
|
||||
#endif
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
struct _MyApplication {
|
||||
GtkApplication parent_instance;
|
||||
char** dart_entrypoint_arguments;
|
||||
};
|
||||
|
||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||
|
||||
// Called when first Flutter frame received.
|
||||
static void first_frame_cb(MyApplication* self, FlView* view) {
|
||||
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
|
||||
}
|
||||
|
||||
// Implements GApplication::activate.
|
||||
static void my_application_activate(GApplication* application) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
GtkWindow* window =
|
||||
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
|
||||
|
||||
// Use a header bar when running in GNOME as this is the common style used
|
||||
// by applications and is the setup most users will be using (e.g. Ubuntu
|
||||
// desktop).
|
||||
// If running on X and not using GNOME then just use a traditional title bar
|
||||
// in case the window manager does more exotic layout, e.g. tiling.
|
||||
// If running on Wayland assume the header bar will work (may need changing
|
||||
// if future cases occur).
|
||||
gboolean use_header_bar = TRUE;
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
GdkScreen* screen = gtk_window_get_screen(window);
|
||||
if (GDK_IS_X11_SCREEN(screen)) {
|
||||
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
|
||||
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
|
||||
use_header_bar = FALSE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "Ground Suite");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "Ground Suite");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
g_autoptr(FlDartProject) project = fl_dart_project_new();
|
||||
fl_dart_project_set_dart_entrypoint_arguments(
|
||||
project, self->dart_entrypoint_arguments);
|
||||
|
||||
FlView* view = fl_view_new(project);
|
||||
GdkRGBA background_color;
|
||||
// Background defaults to black, override it here if necessary, e.g. #00000000
|
||||
// for transparent.
|
||||
gdk_rgba_parse(&background_color, "#000000");
|
||||
fl_view_set_background_color(view, &background_color);
|
||||
gtk_widget_show(GTK_WIDGET(view));
|
||||
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
|
||||
|
||||
// Show the window when Flutter renders.
|
||||
// Requires the view to be realized so we can start rendering.
|
||||
g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
|
||||
self);
|
||||
gtk_widget_realize(GTK_WIDGET(view));
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
|
||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||
}
|
||||
|
||||
// Implements GApplication::local_command_line.
|
||||
static gboolean my_application_local_command_line(GApplication* application,
|
||||
gchar*** arguments,
|
||||
int* exit_status) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
// Strip out the first argument as it is the binary name.
|
||||
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
|
||||
|
||||
g_autoptr(GError) error = nullptr;
|
||||
if (!g_application_register(application, nullptr, &error)) {
|
||||
g_warning("Failed to register: %s", error->message);
|
||||
*exit_status = 1;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
g_application_activate(application);
|
||||
*exit_status = 0;
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Implements GApplication::startup.
|
||||
static void my_application_startup(GApplication* application) {
|
||||
// MyApplication* self = MY_APPLICATION(object);
|
||||
|
||||
// Perform any actions required at application startup.
|
||||
|
||||
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
|
||||
}
|
||||
|
||||
// Implements GApplication::shutdown.
|
||||
static void my_application_shutdown(GApplication* application) {
|
||||
// MyApplication* self = MY_APPLICATION(object);
|
||||
|
||||
// Perform any actions required at application shutdown.
|
||||
|
||||
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
|
||||
}
|
||||
|
||||
// Implements GObject::dispose.
|
||||
static void my_application_dispose(GObject* object) {
|
||||
MyApplication* self = MY_APPLICATION(object);
|
||||
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
||||
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
||||
}
|
||||
|
||||
static void my_application_class_init(MyApplicationClass* klass) {
|
||||
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
|
||||
G_APPLICATION_CLASS(klass)->local_command_line =
|
||||
my_application_local_command_line;
|
||||
G_APPLICATION_CLASS(klass)->startup = my_application_startup;
|
||||
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
|
||||
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
|
||||
}
|
||||
|
||||
static void my_application_init(MyApplication* self) {}
|
||||
|
||||
MyApplication* my_application_new() {
|
||||
// Set the program name to the application ID, which helps various systems
|
||||
// like GTK and desktop environments map this running application to its
|
||||
// corresponding .desktop file. This ensures better integration by allowing
|
||||
// the application to be recognized beyond its binary name.
|
||||
g_set_prgname(APPLICATION_ID);
|
||||
|
||||
return MY_APPLICATION(g_object_new(my_application_get_type(),
|
||||
"application-id", APPLICATION_ID, "flags",
|
||||
G_APPLICATION_NON_UNIQUE, nullptr));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#ifndef FLUTTER_MY_APPLICATION_H_
|
||||
#define FLUTTER_MY_APPLICATION_H_
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_DECLARE_FINAL_TYPE(MyApplication,
|
||||
my_application,
|
||||
MY,
|
||||
APPLICATION,
|
||||
GtkApplication)
|
||||
|
||||
/**
|
||||
* my_application_new:
|
||||
*
|
||||
* Creates a new Flutter-based application.
|
||||
*
|
||||
* Returns: a new #MyApplication.
|
||||
*/
|
||||
MyApplication* my_application_new();
|
||||
|
||||
#endif // FLUTTER_MY_APPLICATION_H_
|
||||
@@ -0,0 +1,525 @@
|
||||
# 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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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.18.0-18.0.pre.54"
|
||||
@@ -0,0 +1,87 @@
|
||||
name: ground_suite
|
||||
description: "Kaptan's desktop ground-control observability console."
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ground_suite/core/observations/observed_event.dart';
|
||||
import 'package:ground_suite/core/observations/vehicle_data_update.dart';
|
||||
import 'package:ground_suite/core/transport/replay_vehicle_data_port.dart';
|
||||
|
||||
void main() {
|
||||
test('replay covers identity, telemetry, log, error, and shutdown', () async {
|
||||
const port = ReplayVehicleDataPort();
|
||||
|
||||
final events = [
|
||||
for (final update in await port.observe().toList())
|
||||
if (update case ObservationReceived(:final event)) event,
|
||||
];
|
||||
|
||||
expect(events.whereType<HeartbeatEvent>(), hasLength(1));
|
||||
expect(events.whereType<TelemetryEvent>(), hasLength(2));
|
||||
final logs = events.whereType<LogEvent>().toList();
|
||||
final errors = events.whereType<ErrorEvent>().toList();
|
||||
expect(logs, hasLength(2));
|
||||
expect(errors, hasLength(1));
|
||||
expect(events.whereType<ShutdownEvent>(), hasLength(1));
|
||||
expect(logs.first.isCoalesced, isTrue);
|
||||
expect(errors.single.isTruncated, isTrue);
|
||||
});
|
||||
|
||||
test('unknown wire enum values remain visible', () {
|
||||
expect(const MavSeverity(99).label, 'Unknown (99)');
|
||||
expect(const KaptanShutdownReason(99).label, 'Unknown (99)');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ground_suite/core/observations/observed_event.dart';
|
||||
import 'package:ground_suite/core/observations/vehicle_data_port.dart';
|
||||
import 'package:ground_suite/core/observations/vehicle_data_update.dart';
|
||||
import 'package:ground_suite/features/observability/presentation/observability_controller.dart';
|
||||
|
||||
void main() {
|
||||
test('coalesces rapid critical accessibility announcements', () async {
|
||||
final port = _ControlledDataPort();
|
||||
final container = ProviderContainer(
|
||||
overrides: [vehicleDataPortProvider.overrideWithValue(port)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(observabilityControllerProvider);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
port.events.add(
|
||||
ObservationReceived(
|
||||
ErrorEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10),
|
||||
path: 'kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
|
||||
),
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(
|
||||
container.read(observabilityControllerProvider).accessibilityAnnouncement,
|
||||
'Error received: kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
|
||||
);
|
||||
|
||||
port.events.add(
|
||||
ObservationReceived(
|
||||
ShutdownEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 0, 0, 1),
|
||||
reason: KaptanShutdownReason.safety,
|
||||
),
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final state = container.read(observabilityControllerProvider);
|
||||
expect(state.accessibilityAnnouncement, isNull);
|
||||
expect(state.coalescedAlertCount, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'retains expected parser and semantic drops without ending observation',
|
||||
() async {
|
||||
final port = _ControlledDataPort();
|
||||
final container = ProviderContainer(
|
||||
overrides: [vehicleDataPortProvider.overrideWithValue(port)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(observabilityControllerProvider);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
port.events.add(
|
||||
const ObservationDropped(kind: ObservationDropKind.parser, count: 2),
|
||||
);
|
||||
port.events.add(
|
||||
const ObservationDropped(kind: ObservationDropKind.semantic),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final state = container.read(observabilityControllerProvider);
|
||||
expect(state.status, ObservationStatus.receiving);
|
||||
expect(state.parserDropCount, 2);
|
||||
expect(state.semanticDropCount, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test('treats a transport stream failure as a disconnected link', () async {
|
||||
final port = _ControlledDataPort();
|
||||
final container = ProviderContainer(
|
||||
overrides: [vehicleDataPortProvider.overrideWithValue(port)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(observabilityControllerProvider);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
port.events.addError(StateError('serial endpoint closed'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final state = container.read(observabilityControllerProvider);
|
||||
expect(state.status, ObservationStatus.disconnected);
|
||||
expect(state.parserDropCount, 0);
|
||||
expect(state.semanticDropCount, 0);
|
||||
});
|
||||
}
|
||||
|
||||
class _ControlledDataPort implements VehicleDataPort {
|
||||
final events = StreamController<VehicleDataUpdate>();
|
||||
|
||||
@override
|
||||
bool get isReplay => false;
|
||||
|
||||
@override
|
||||
String get label => 'Test transport';
|
||||
|
||||
@override
|
||||
Future<void> close() => events.close();
|
||||
|
||||
@override
|
||||
Stream<VehicleDataUpdate> observe() => events.stream;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ground_suite/app/app.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('replay workspace keeps diagnostics distinct and read-only', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Observation only'), findsOneWidget);
|
||||
expect(find.text('Replay fixture'), findsAtLeastNWidgets(1));
|
||||
expect(find.text('Vehicle identity'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.receipt_long_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Diagnostics'), findsAtLeastNWidgets(1));
|
||||
expect(
|
||||
find.text('kaptan_drivers::lidar_lite_v3::error::Error::TransferFault'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Shutdowns'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Orderly shutdown observed'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'compact desktop layout uses bottom navigation without overflow',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(480, 800));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user