feat(ground-suite): add tactical command workspace
This commit is contained in:
1 parent
00a3a9f908
commit
35e0fd8741
10 files changed
+5435
-874
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
@@ -3,20 +3,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app_shell.dart';
|
||||
import 'theme.dart';
|
||||
import 'theme_mode_controller.dart';
|
||||
|
||||
class GroundSuiteApp extends ConsumerWidget {
|
||||
class GroundSuiteApp extends StatelessWidget {
|
||||
const GroundSuiteApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return MaterialApp(
|
||||
Widget build(BuildContext context) => ProviderScope(
|
||||
child: MaterialApp(
|
||||
title: 'Ground Suite',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: groundSuiteLightTheme(),
|
||||
darkTheme: groundSuiteDarkTheme(),
|
||||
themeMode: ref.watch(themeModeProvider),
|
||||
theme: groundSuiteDarkTheme(),
|
||||
themeMode: ThemeMode.dark,
|
||||
home: const AppShell(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../features/command/presentation/tactical_command_page.dart';
|
||||
import '../features/diagnostics/presentation/diagnostics_page.dart';
|
||||
import '../features/observability/presentation/observability_controller.dart';
|
||||
import '../features/overview/presentation/overview_page.dart';
|
||||
import '../shared/widgets/surfaces.dart';
|
||||
import 'theme_mode_controller.dart';
|
||||
|
||||
enum _Destination { vehicle, events }
|
||||
enum _ApplicationSurface { tactical, eventStream }
|
||||
|
||||
/// Owns the two first-class Ground Suite surfaces. Tactical planning never
|
||||
/// hides decoded diagnostics or critical accessibility announcements.
|
||||
class AppShell extends ConsumerStatefulWidget {
|
||||
const AppShell({super.key});
|
||||
|
||||
@@ -17,425 +17,55 @@ class AppShell extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _AppShellState extends ConsumerState<AppShell> {
|
||||
_Destination _destination = _Destination.vehicle;
|
||||
_ApplicationSurface _surface = _ApplicationSurface.tactical;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(observabilityControllerProvider);
|
||||
const pages = <Widget>[OverviewPage(), DiagnosticsPage()];
|
||||
|
||||
final observation = ref.watch(observabilityControllerProvider);
|
||||
return Scaffold(
|
||||
body: AppCanvas(
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
body: Stack(
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final textScale =
|
||||
MediaQuery.textScalerOf(context).scale(14) / 14;
|
||||
final compact =
|
||||
constraints.maxWidth < 960 || textScale > 1.25;
|
||||
final workspace = _Workspace(
|
||||
state: state,
|
||||
selectedIndex: _destination.index,
|
||||
pages: pages,
|
||||
compact: compact,
|
||||
);
|
||||
return compact
|
||||
? _CompactShell(
|
||||
selectedIndex: _destination.index,
|
||||
onSelected: _selectDestination,
|
||||
workspace: workspace,
|
||||
)
|
||||
: _DesktopShell(
|
||||
selectedIndex: _destination.index,
|
||||
onSelected: _selectDestination,
|
||||
workspace: workspace,
|
||||
);
|
||||
switch (_surface) {
|
||||
_ApplicationSurface.tactical => TacticalCommandPage(
|
||||
sourceLabel: observation.sourceLabel,
|
||||
sourceStatus: observation.status.label,
|
||||
onOpenEventStream: () =>
|
||||
setState(() => _surface = _ApplicationSurface.eventStream),
|
||||
),
|
||||
_ApplicationSurface.eventStream => _EventStreamWorkspace(
|
||||
onReturnToTactical: () =>
|
||||
setState(() => _surface = _ApplicationSurface.tactical),
|
||||
),
|
||||
},
|
||||
),
|
||||
if (state.accessibilityAnnouncement case final announcement?)
|
||||
_CriticalAnnouncement(message: announcement),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _selectDestination(int index) {
|
||||
setState(() => _destination = _Destination.values[index]);
|
||||
}
|
||||
}
|
||||
|
||||
class _DesktopShell extends StatelessWidget {
|
||||
const _DesktopShell({
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
required this.workspace,
|
||||
});
|
||||
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
final Widget workspace;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
FrostedChrome(
|
||||
child: SizedBox(
|
||||
width: 216,
|
||||
child: NavigationRail(
|
||||
extended: true,
|
||||
minExtendedWidth: 216,
|
||||
selectedIndex: selectedIndex,
|
||||
labelType: NavigationRailLabelType.none,
|
||||
onDestinationSelected: onSelected,
|
||||
leading: const _BrandBlock(),
|
||||
trailing: const Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 0, 12, 16),
|
||||
child: AppearanceMenu(showLabel: true),
|
||||
),
|
||||
destinations: const [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.radar_outlined),
|
||||
selectedIcon: Icon(Icons.radar),
|
||||
label: Text('Vehicle observation'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.segment_outlined),
|
||||
selectedIcon: Icon(Icons.segment),
|
||||
label: Text('Event stream'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: workspace),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BrandBlock extends StatelessWidget {
|
||||
const _BrandBlock();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 16, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.flight_takeoff_outlined, color: theme.colorScheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
const ConsoleLabel('Kaptan'),
|
||||
Text('Ground Suite', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text('Observation console', style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompactShell extends StatelessWidget {
|
||||
const _CompactShell({
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
required this.workspace,
|
||||
});
|
||||
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
final Widget workspace;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(child: workspace),
|
||||
FrostedChrome(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
child: Row(
|
||||
key: const Key('compact-navigation'),
|
||||
children: [
|
||||
Expanded(
|
||||
child: _CompactDestinationButton(
|
||||
selected: selectedIndex == _Destination.vehicle.index,
|
||||
icon: Icons.radar_outlined,
|
||||
selectedIcon: Icons.radar,
|
||||
label: 'Vehicle',
|
||||
onTap: () => onSelected(_Destination.vehicle.index),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _CompactDestinationButton(
|
||||
selected: selectedIndex == _Destination.events.index,
|
||||
icon: Icons.segment_outlined,
|
||||
selectedIcon: Icons.segment,
|
||||
label: 'Events',
|
||||
onTap: () => onSelected(_Destination.events.index),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompactDestinationButton extends StatelessWidget {
|
||||
const _CompactDestinationButton({
|
||||
required this.selected,
|
||||
required this.icon,
|
||||
required this.selectedIcon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool selected;
|
||||
final IconData icon;
|
||||
final IconData selectedIcon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final tone = selected ? scheme.primary : scheme.onSurfaceVariant;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: label,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(selected ? selectedIcon : icon, color: tone),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: tone,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: [
|
||||
_OperationalStatusStrip(state: state, compact: compact),
|
||||
Expanded(child: pages[selectedIndex]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OperationalStatusStrip extends StatelessWidget {
|
||||
const _OperationalStatusStrip({required this.state, required this.compact});
|
||||
|
||||
final ObservabilityState state;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final heartbeat = state.lastHeartbeat;
|
||||
final dark = Theme.of(context).brightness == Brightness.dark;
|
||||
final replayTone = dark ? const Color(0xff70b8ff) : const Color(0xff175cd3);
|
||||
final source = state.isReplay ? 'Replay · fixture' : state.sourceLabel;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(14) / 14;
|
||||
final showFacts =
|
||||
!compact && constraints.maxWidth >= 900 && textScale <= 1.25;
|
||||
return FrostedChrome(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 16 : 20,
|
||||
vertical: 10,
|
||||
),
|
||||
child: showFacts
|
||||
? Row(
|
||||
children: [
|
||||
StatusBadge(label: source, color: replayTone),
|
||||
const SizedBox(width: 8),
|
||||
const StatusBadge(label: 'Observation only'),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
state.status.label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const VerticalDivider(indent: 4, endIndent: 4),
|
||||
Text(
|
||||
heartbeat == null
|
||||
? 'Awaiting reported heartbeat'
|
||||
: 'SYS ${heartbeat.systemId} · COMP ${heartbeat.componentId}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Ingress ${state.parserDropCount} / ${state.semanticDropCount}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
state.lastFrameAt == null
|
||||
? 'No observations'
|
||||
: state.isReplay
|
||||
? 'Replay ended ${_shortTimestamp(state.lastFrameAt!)}'
|
||||
: 'Last ${_shortTimestamp(state.lastFrameAt!)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
)
|
||||
: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
StatusBadge(label: source, color: replayTone),
|
||||
const StatusBadge(label: 'Observation only'),
|
||||
const AppearanceMenu(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppearanceMenu extends ConsumerWidget {
|
||||
const AppearanceMenu({super.key, this.showLabel = false});
|
||||
|
||||
final bool showLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mode = ref.watch(themeModeProvider);
|
||||
final label = _appearanceLabel(mode);
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Appearance: $label',
|
||||
child: Tooltip(
|
||||
message: 'Appearance: $label',
|
||||
child: PopupMenuButton<ThemeMode>(
|
||||
initialValue: mode,
|
||||
constraints: const BoxConstraints(minWidth: 300, maxWidth: 360),
|
||||
onSelected: ref.read(themeModeProvider.notifier).select,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_appearanceIcon(mode), size: 20),
|
||||
if (showLabel) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Appearance',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
enabled: false,
|
||||
child: ConsoleLabel('Appearance'),
|
||||
),
|
||||
_appearanceItem(
|
||||
ThemeMode.system,
|
||||
mode,
|
||||
'System default (recommended)',
|
||||
),
|
||||
_appearanceItem(ThemeMode.light, mode, 'Light'),
|
||||
_appearanceItem(ThemeMode.dark, mode, 'Dark'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PopupMenuItem<ThemeMode> _appearanceItem(
|
||||
ThemeMode value,
|
||||
ThemeMode selected,
|
||||
String label,
|
||||
) => PopupMenuItem(
|
||||
value: value,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(value == selected ? Icons.check : Icons.circle_outlined, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(label, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _CriticalAnnouncement extends StatelessWidget {
|
||||
const _CriticalAnnouncement({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Semantics(
|
||||
container: true,
|
||||
Semantics(
|
||||
liveRegion: true,
|
||||
label: message,
|
||||
child: const SizedBox.square(dimension: 1),
|
||||
label: observation.accessibilityAnnouncement ?? '',
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _appearanceLabel(ThemeMode mode) => switch (mode) {
|
||||
ThemeMode.system => 'System default',
|
||||
ThemeMode.light => 'Light',
|
||||
ThemeMode.dark => 'Dark',
|
||||
};
|
||||
class _EventStreamWorkspace extends StatelessWidget {
|
||||
const _EventStreamWorkspace({required this.onReturnToTactical});
|
||||
|
||||
IconData _appearanceIcon(ThemeMode mode) => switch (mode) {
|
||||
ThemeMode.system => Icons.brightness_auto_outlined,
|
||||
ThemeMode.light => Icons.light_mode_outlined,
|
||||
ThemeMode.dark => Icons.dark_mode_outlined,
|
||||
};
|
||||
final VoidCallback onReturnToTactical;
|
||||
|
||||
String _shortTimestamp(DateTime value) =>
|
||||
'${value.hour.toString().padLeft(2, '0')}:'
|
||||
'${value.minute.toString().padLeft(2, '0')}:'
|
||||
'${value.second.toString().padLeft(2, '0')}Z';
|
||||
@override
|
||||
Widget build(BuildContext context) => Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: DiagnosticsPage()),
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 20,
|
||||
child: FilledButton.icon(
|
||||
onPressed: onReturnToTactical,
|
||||
icon: const Icon(Icons.map_outlined),
|
||||
label: const Text('Tactical workspace'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
+57
-104
@@ -1,117 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
/// A restrained green accent, used sparingly against neutral surfaces.
|
||||
const accentGreen = Color(0xff36d681);
|
||||
/// Ground Suite's single visual language: a dark tactical operations deck.
|
||||
const panelBackground = Color(0xD912141A);
|
||||
const panelBorder = Color(0x1AFFFFFF);
|
||||
const textPrimary = Color(0xFFF8FAFC);
|
||||
const textSecondary = Color(0xFF94A3B8);
|
||||
const accentOrange = Color(0xFFF97316);
|
||||
const accentMint = Color(0xFF34D399);
|
||||
const statusGreen = Color(0xFF10B981);
|
||||
const statusRed = Color(0xFFEF4444);
|
||||
|
||||
ThemeData groundSuiteLightTheme() => _theme(
|
||||
brightness: Brightness.light,
|
||||
seed: const Color(0xff087a3b),
|
||||
surface: const Color(0xfff3f6f3),
|
||||
onSurface: const Color(0xff142019),
|
||||
outline: const Color(0xffc2cec4),
|
||||
);
|
||||
const tacticalBlack = Color(0xFF000000);
|
||||
const tacticalTransparent = Color(0x00000000);
|
||||
|
||||
ThemeData groundSuiteDarkTheme() => _theme(
|
||||
brightness: Brightness.dark,
|
||||
seed: const Color(0xff36d681),
|
||||
surface: const Color(0xff080b09),
|
||||
onSurface: const Color(0xffe8f0ea),
|
||||
outline: const Color(0xff2a382e),
|
||||
);
|
||||
ThemeData groundSuiteDarkTheme() {
|
||||
final base = ThemeData.dark(useMaterial3: true);
|
||||
final interTextTheme = GoogleFonts.interTextTheme(
|
||||
base.textTheme,
|
||||
).apply(bodyColor: textPrimary, displayColor: textPrimary);
|
||||
|
||||
ThemeData _theme({
|
||||
required Brightness brightness,
|
||||
required Color seed,
|
||||
required Color surface,
|
||||
required Color onSurface,
|
||||
required Color outline,
|
||||
}) {
|
||||
final scheme = ColorScheme.fromSeed(seedColor: seed, brightness: brightness)
|
||||
.copyWith(
|
||||
primary: seed,
|
||||
onPrimary: brightness == Brightness.dark
|
||||
? const Color(0xff031207)
|
||||
: Colors.white,
|
||||
surface: surface,
|
||||
onSurface: onSurface,
|
||||
onSurfaceVariant: brightness == Brightness.dark
|
||||
? const Color(0xffa8b5ac)
|
||||
: const Color(0xff546258),
|
||||
outline: outline,
|
||||
surfaceContainer: brightness == Brightness.dark
|
||||
? const Color(0xff101712)
|
||||
: const Color(0xffeaf0eb),
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: Colors.transparent,
|
||||
visualDensity: VisualDensity.standard,
|
||||
cardTheme: const CardThemeData(
|
||||
color: Colors.transparent,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
return base.copyWith(
|
||||
scaffoldBackgroundColor: tacticalBlack,
|
||||
cardColor: panelBackground,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: accentMint,
|
||||
onPrimary: tacticalBlack,
|
||||
secondary: accentOrange,
|
||||
onSecondary: tacticalBlack,
|
||||
surface: panelBackground,
|
||||
onSurface: textPrimary,
|
||||
error: statusRed,
|
||||
onError: textPrimary,
|
||||
outline: panelBorder,
|
||||
),
|
||||
textTheme: ThemeData(brightness: brightness).textTheme.copyWith(
|
||||
headlineMedium: TextStyle(
|
||||
color: onSurface,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.6,
|
||||
textTheme: interTextTheme.copyWith(
|
||||
displayLarge: interTextTheme.displayLarge?.copyWith(color: textPrimary),
|
||||
displayMedium: interTextTheme.displayMedium?.copyWith(color: textPrimary),
|
||||
headlineLarge: interTextTheme.headlineLarge?.copyWith(color: textPrimary),
|
||||
headlineMedium: interTextTheme.headlineMedium?.copyWith(
|
||||
color: textPrimary,
|
||||
),
|
||||
titleLarge: TextStyle(color: onSurface, fontWeight: FontWeight.w700),
|
||||
titleMedium: TextStyle(color: onSurface, fontWeight: FontWeight.w600),
|
||||
bodyMedium: TextStyle(color: onSurface),
|
||||
bodySmall: TextStyle(
|
||||
color: brightness == Brightness.dark
|
||||
? const Color(0xffa8b5ac)
|
||||
: const Color(0xff546258),
|
||||
),
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: outline.withValues(alpha: 0.72)),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: Colors.transparent,
|
||||
indicatorColor: seed.withValues(
|
||||
alpha: brightness == Brightness.dark ? 0.2 : 0.13,
|
||||
),
|
||||
selectedIconTheme: IconThemeData(color: seed),
|
||||
selectedLabelTextStyle: TextStyle(
|
||||
color: seed,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: Colors.transparent,
|
||||
indicatorColor: seed.withValues(
|
||||
alpha: brightness == Brightness.dark ? 0.2 : 0.13,
|
||||
),
|
||||
labelTextStyle: WidgetStatePropertyAll(
|
||||
TextStyle(color: onSurface, fontWeight: FontWeight.w600),
|
||||
headlineSmall: interTextTheme.headlineSmall?.copyWith(color: textPrimary),
|
||||
titleLarge: interTextTheme.titleLarge?.copyWith(color: textPrimary),
|
||||
titleMedium: interTextTheme.titleMedium?.copyWith(color: textPrimary),
|
||||
titleSmall: interTextTheme.titleSmall?.copyWith(color: textPrimary),
|
||||
bodyLarge: interTextTheme.bodyLarge?.copyWith(color: textPrimary),
|
||||
bodyMedium: interTextTheme.bodyMedium?.copyWith(color: textPrimary),
|
||||
bodySmall: interTextTheme.bodySmall?.copyWith(color: textSecondary),
|
||||
labelLarge: interTextTheme.labelLarge?.copyWith(color: textPrimary),
|
||||
labelMedium: interTextTheme.labelMedium?.copyWith(color: textSecondary),
|
||||
labelSmall: interTextTheme.labelSmall?.copyWith(color: textSecondary),
|
||||
),
|
||||
cardTheme: const CardThemeData(color: panelBackground, elevation: 0),
|
||||
dividerTheme: const DividerThemeData(color: panelBorder),
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(
|
||||
color: accentMint,
|
||||
linearTrackColor: textSecondary.withValues(alpha: .2),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: scheme.surfaceContainer.withValues(alpha: 0.72),
|
||||
selectedColor: seed.withValues(
|
||||
alpha: brightness == Brightness.dark ? 0.22 : 0.16,
|
||||
),
|
||||
side: BorderSide(color: outline.withValues(alpha: 0.75)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: scheme.surfaceContainer.withValues(alpha: 0.7),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: outline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: outline.withValues(alpha: 0.8)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: seed, width: 1.5),
|
||||
),
|
||||
backgroundColor: tacticalTransparent,
|
||||
selectedColor: accentMint.withValues(alpha: .1),
|
||||
side: const BorderSide(color: panelBorder),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
labelStyle: GoogleFonts.inter(color: textSecondary),
|
||||
secondaryLabelStyle: GoogleFonts.inter(color: textPrimary),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final themeModeProvider = NotifierProvider<ThemeModeController, ThemeMode>(
|
||||
ThemeModeController.new,
|
||||
);
|
||||
|
||||
/// Holds the local display preference for the current application session.
|
||||
///
|
||||
/// It intentionally starts at the system preference. Persisting an operator's
|
||||
/// visual preference can be added later without coupling it to vehicle state.
|
||||
class ThemeModeController extends Notifier<ThemeMode> {
|
||||
@override
|
||||
ThemeMode build() => ThemeMode.system;
|
||||
|
||||
void select(ThemeMode mode) => state = mode;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,283 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/observations/observed_event.dart';
|
||||
import '../../../shared/widgets/surfaces.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 observedStreams = state.observedTelemetryKinds;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final padding = constraints.maxWidth < 600 ? 16.0 : 28.0;
|
||||
final columns = constraints.maxWidth >= 1120
|
||||
? 3
|
||||
: constraints.maxWidth >= 700
|
||||
? 2
|
||||
: 1;
|
||||
final panelWidth =
|
||||
(constraints.maxWidth - (padding * 2) - (16 * (columns - 1))) /
|
||||
columns;
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.all(padding),
|
||||
children: [
|
||||
const ConsoleLabel('Vehicle observation'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Replay session',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Read-only observations from the selected source.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
_MetricPanel(
|
||||
width: panelWidth,
|
||||
label: 'Replay session',
|
||||
value: state.sourceLabel,
|
||||
accent: Theme.of(context).colorScheme.secondary,
|
||||
child: const Text(
|
||||
'Deterministic fixture. No physical vehicle is connected.',
|
||||
),
|
||||
),
|
||||
_MetricPanel(
|
||||
width: panelWidth,
|
||||
label: 'Last reported heartbeat',
|
||||
value: heartbeat == null
|
||||
? 'Awaiting heartbeat'
|
||||
: 'SYS ${heartbeat.systemId} · COMP ${heartbeat.componentId}',
|
||||
monoValue: true,
|
||||
child: _HeartbeatFacts(heartbeat: heartbeat),
|
||||
),
|
||||
_MetricPanel(
|
||||
width: panelWidth,
|
||||
label: 'Ingress accounting',
|
||||
value:
|
||||
'${state.parserDropCount} / ${state.semanticDropCount}',
|
||||
monoValue: true,
|
||||
child: Text(
|
||||
state.isReplay
|
||||
? 'Parser / semantic drops. Not evaluated for typed replay.'
|
||||
: 'Parser / semantic drops from received frames.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InstrumentPanel(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ConsoleLabel('Observed data streams'),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
for (final kind in TelemetryKind.values)
|
||||
_StreamIndicator(
|
||||
kind: kind,
|
||||
observed: observedStreams.contains(kind),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InstrumentPanel(
|
||||
accent: Theme.of(context).colorScheme.outline,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: _UnavailableSetup(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricPanel extends StatelessWidget {
|
||||
const _MetricPanel({
|
||||
required this.width,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.child,
|
||||
this.accent,
|
||||
this.monoValue = false,
|
||||
});
|
||||
|
||||
final double width;
|
||||
final String label;
|
||||
final String value;
|
||||
final Widget child;
|
||||
final Color? accent;
|
||||
final bool monoValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
width: width,
|
||||
child: InstrumentPanel(
|
||||
accent: accent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ConsoleLabel(label),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontFamily: monoValue ? 'monospace' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
DefaultTextStyle.merge(
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _HeartbeatFacts extends StatelessWidget {
|
||||
const _HeartbeatFacts({required this.heartbeat});
|
||||
|
||||
final HeartbeatEvent? heartbeat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (heartbeat == null) {
|
||||
return const Text('No heartbeat has been reported by this source.');
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_FactLine(
|
||||
label: 'Reported arm state',
|
||||
value: heartbeat!.armed ? 'ARMED' : 'DISARMED',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_FactLine(
|
||||
label: 'Reported MAV state',
|
||||
value: heartbeat!.vehicleState.toUpperCase(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FactLine extends StatelessWidget {
|
||||
const _FactLine({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => RichText(
|
||||
text: TextSpan(
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
children: [
|
||||
TextSpan(text: '$label: '),
|
||||
TextSpan(
|
||||
text: value,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _StreamIndicator extends StatelessWidget {
|
||||
const _StreamIndicator({required this.kind, required this.observed});
|
||||
|
||||
final TelemetryKind kind;
|
||||
final bool observed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final tone = observed ? scheme.primary : scheme.onSurfaceVariant;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: tone.withValues(alpha: observed ? 0.12 : 0.06),
|
||||
border: Border.all(
|
||||
color: tone.withValues(alpha: observed ? 0.5 : 0.32),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
child: Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
observed
|
||||
? Icons.check_circle_outline
|
||||
: Icons.remove_circle_outline,
|
||||
size: 17,
|
||||
color: tone,
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Text(kind.label),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
observed ? 'Observed' : 'Not observed',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnavailableSetup extends StatelessWidget {
|
||||
const _UnavailableSetup();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.lock_outline),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ConsoleLabel('Vehicle setup · unavailable'),
|
||||
const SizedBox(height: 8),
|
||||
Text('This firmware accepts no inbound MAVLink authority.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app/app.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: GroundSuiteApp()));
|
||||
runApp(const GroundSuiteApp());
|
||||
}
|
||||
@@ -131,7 +131,7 @@ class AppCanvas extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final dark = Theme.of(context).brightness == Brightness.dark;
|
||||
final base = dark ? const Color(0xff080b09) : const Color(0xfff3f6f3);
|
||||
final primaryHaze = (dark ? accentGreen : const Color(0xff087a3b))
|
||||
final primaryHaze = (dark ? accentMint : const Color(0xff087a3b))
|
||||
.withValues(alpha: dark ? 0.075 : 0.05);
|
||||
|
||||
return ColoredBox(
|
||||
|
||||
@@ -1,96 +1,683 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ground_suite/app/app.dart';
|
||||
import 'package:ground_suite/app/app_shell.dart';
|
||||
import 'package:ground_suite/shared/widgets/surfaces.dart';
|
||||
import 'package:ground_suite/app/theme.dart';
|
||||
import 'package:ground_suite/features/command/presentation/tactical_command_page.dart';
|
||||
import 'package:ground_suite/features/command/presentation/tactical_controller.dart';
|
||||
import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart';
|
||||
import 'package:ground_suite/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('replay workspace keeps diagnostics distinct and read-only', (
|
||||
testWidgets('desktop command dashboard opens the grouped tactical controls', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp()));
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
|
||||
expect(app.themeMode, ThemeMode.system);
|
||||
expect(app.theme?.brightness, Brightness.light);
|
||||
expect(app.darkTheme?.brightness, Brightness.dark);
|
||||
expect(find.byType(FrostedChrome), findsAtLeastNWidgets(1));
|
||||
expect(find.text('OBSERVATION ONLY'), findsOneWidget);
|
||||
expect(find.text('Replay fixture'), findsAtLeastNWidgets(1));
|
||||
expect(find.text('LAST REPORTED HEARTBEAT'), findsOneWidget);
|
||||
expect(app.themeMode, ThemeMode.dark);
|
||||
expect(find.text('TACTICAL COMMAND'), findsOneWidget);
|
||||
expect(find.text('SIMULATED CAMERA CONCEPT'), findsOneWidget);
|
||||
expect(find.text('DEMO-1021'), findsAtLeastNWidgets(1));
|
||||
expect(find.byKey(const Key('units-menu')), findsOneWidget);
|
||||
expect(find.byKey(const Key('cursor-menu')), findsOneWidget);
|
||||
expect(find.byKey(const Key('view-menu')), findsOneWidget);
|
||||
expect(find.text('UNITS'), findsNothing);
|
||||
expect(find.text('MODE: SELECTION'), findsNothing);
|
||||
expect(find.text('VIEW'), findsNothing);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('units-menu'))),
|
||||
const Size(48, 48),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('cursor-menu'))),
|
||||
const Size(48, 48),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('view-menu'))),
|
||||
const Size(48, 48),
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Appearance: System default'));
|
||||
await tester.tap(find.byKey(const Key('units-menu')));
|
||||
await tester.pump();
|
||||
expect(find.text('Add local unit'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Requires verified source-to-unit binding'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Add local unit'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Dark'));
|
||||
expect(find.text('UNIT HANGAR'), findsOneWidget);
|
||||
expect(find.text('LOCAL SCENE'), findsAtLeastNWidgets(1));
|
||||
expect(find.byKey(const Key('unit-hangar-strip')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('hangar-card-kaptan.vehicle.quadcopter.local')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('hangar-card-kaptan.vehicle.fixed-wing.local')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('hangar-card-kaptan.vehicle.tiltrotor-vtol.local')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('hangar-card-kaptan.vehicle.helicopter.local')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('hangar-card-kaptan.vehicle.rover.local')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('hangar-card-kaptan.vehicle.surface-vessel.local')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const Key('hangar-card-kaptan.vehicle.quadcopter.local')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('PREPARE UNIT'), findsOneWidget);
|
||||
expect(find.text('QUADCOPTER-01'), findsNothing);
|
||||
expect(
|
||||
ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
).read(tacticalControllerProvider).unitDraft,
|
||||
isNotNull,
|
||||
);
|
||||
|
||||
final rectangle = await tester.startGesture(const Offset(620, 500));
|
||||
await rectangle.moveTo(const Offset(760, 560));
|
||||
await rectangle.up();
|
||||
await tester.pumpAndSettle();
|
||||
final draftState = ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
).read(tacticalControllerProvider);
|
||||
expect(draftState.panel, TacticalPanel.unitHangar);
|
||||
expect(draftState.unitDraft?.definition.name, 'Quadcopter');
|
||||
|
||||
await tester.tap(find.text('PREPARE UNIT'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('UNIT SETUP'), findsOneWidget);
|
||||
expect(find.text('VEHICLE CONFIGURATION UNAVAILABLE'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'Unavailable until verified binding, compatible schema, command authority, and safe activation are available.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('ADD TO LOCAL SCENE'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
).read(tacticalControllerProvider).mode,
|
||||
isA<TacticalBrowseMode>(),
|
||||
);
|
||||
expect(find.text('QUADCOPTER-01'), findsAtLeastNWidgets(1));
|
||||
|
||||
await tester.tap(find.text('SET UP'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('LOCAL UNIT SETUP'), findsOneWidget);
|
||||
expect(find.byKey(const Key('local-unit-name-field')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('dark tactical theme is fixed and uses the palette tokens', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final context = tester.element(find.byType(AppShell));
|
||||
expect(
|
||||
tester.widget<MaterialApp>(find.byType(MaterialApp)).themeMode,
|
||||
ThemeMode.dark,
|
||||
);
|
||||
expect(Theme.of(context).brightness, Brightness.dark);
|
||||
expect(Theme.of(context).cardColor, panelBackground);
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(
|
||||
find.text(
|
||||
'LOCAL SCENE DATA · NO VEHICLE LINK · COMMAND AUTHORITY UNAVAILABLE',
|
||||
),
|
||||
)
|
||||
.style
|
||||
?.color,
|
||||
accentOrange,
|
||||
);
|
||||
});
|
||||
|
||||
await tester.tap(find.byIcon(Icons.segment_outlined));
|
||||
testWidgets('selection console stays local and exposes no vehicle action', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Event timeline'), findsOneWidget);
|
||||
await tester.tap(find.text('Errors 1'));
|
||||
await tester.tap(find.byKey(const Key('unit-card-DEMO-1023')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text('kaptan_drivers::lidar_lite_v3::error::Error::TransferFault'),
|
||||
find.text('LOCAL VISUAL PREVIEW · NO VEHICLE ACTION'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('DEPLOY'), findsNothing);
|
||||
expect(find.text('CONFIGURE'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Shutdown reports 1'));
|
||||
await tester.tap(find.text('EVENT STREAM'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Event timeline'), findsOneWidget);
|
||||
await tester.tap(find.text('Tactical workspace'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('TACTICAL COMMAND'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('pencil cursor draws directly on the map with its toolbar', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Orderly shutdown report'), findsOneWidget);
|
||||
ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
).read(tacticalControllerProvider.notifier).beginPencil();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('PENCIL CURSOR'), findsOneWidget);
|
||||
expect(find.byKey(const Key('pencil-hue')), findsOneWidget);
|
||||
expect(find.byKey(const Key('pencil-saturation')), findsOneWidget);
|
||||
expect(find.byKey(const Key('pencil-brightness')), findsOneWidget);
|
||||
expect(find.byKey(const Key('pencil-thickness')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('pencil-swatch-282')));
|
||||
await tester.drag(
|
||||
find.byKey(const Key('pencil-thickness')),
|
||||
const Offset(70, 0),
|
||||
);
|
||||
await tester.pump();
|
||||
final pencil =
|
||||
ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
).read(tacticalControllerProvider).mode
|
||||
as TacticalPencilMode;
|
||||
expect(pencil.hue, 282);
|
||||
expect(pencil.thickness, greaterThan(3));
|
||||
|
||||
final stroke = await tester.startGesture(const Offset(620, 500));
|
||||
await stroke.moveTo(const Offset(680, 540));
|
||||
await tester.pump(const Duration(milliseconds: 20));
|
||||
await stroke.moveTo(const Offset(740, 490));
|
||||
await tester.pump(const Duration(milliseconds: 20));
|
||||
await stroke.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('PENCIL CURSOR'), findsOneWidget);
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
);
|
||||
expect(container.read(tacticalControllerProvider).drawings, hasLength(1));
|
||||
expect(find.text('DRAWING'), findsNothing);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('tactical-map-overlay-painter'))),
|
||||
isNot(Size.zero),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('marker cursor names a clicked local marker separately', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('cursor-menu')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Marker cursor'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('MARKER CURSOR'), findsOneWidget);
|
||||
expect(find.byKey(const Key('marker-hue')), findsOneWidget);
|
||||
expect(find.byKey(const Key('marker-saturation')), findsOneWidget);
|
||||
expect(find.byKey(const Key('marker-brightness')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('marker-swatch-208')));
|
||||
await tester.tapAt(const Offset(700, 520));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('marker-name-field')), findsOneWidget);
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('marker-name-field')),
|
||||
'North staging note',
|
||||
);
|
||||
await tester.tap(find.text('SAVE MARKER'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final state = ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
).read(tacticalControllerProvider);
|
||||
expect(state.markers, hasLength(1));
|
||||
expect(state.markers.single.name, 'North staging note');
|
||||
expect(state.markers.single.hue, 208);
|
||||
expect(state.drawings, isEmpty);
|
||||
expect(find.text('North staging note'), findsAtLeastNWidgets(1));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a Pencil click alone never creates a marker or drawing', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
);
|
||||
container.read(tacticalControllerProvider.notifier).beginPencil();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapAt(const Offset(700, 520));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final state = container.read(tacticalControllerProvider);
|
||||
expect(state.drawings, isEmpty);
|
||||
expect(state.markers, isEmpty);
|
||||
expect(find.byKey(const Key('marker-name-field')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('map marker selection synchronizes the local selection console', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('map-unit-DEMO-1023')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('DEMO-1023'), findsAtLeastNWidgets(1));
|
||||
expect(
|
||||
ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
).read(tacticalControllerProvider).selectedUnitIds,
|
||||
{'DEMO-1023'},
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('dragging the tactical canvas creates a local group selection', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final gesture = await tester.startGesture(const Offset(660, 520));
|
||||
await gesture.moveTo(const Offset(960, 745));
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final selected = ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
).read(tacticalControllerProvider).selectedUnitIds;
|
||||
expect(selected, containsAll(['DEMO-1021', 'DEMO-1023']));
|
||||
expect(find.text('LOCAL GROUP SELECTION'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'compact desktop layout uses bottom navigation without overflow',
|
||||
'map deselection and hidden demo overlays never select invisibly',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(480, 800));
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp()));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('compact-navigation')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.byKey(const Key('map-unit-DEMO-1023')));
|
||||
await tester.pump();
|
||||
await tester.tapAt(const Offset(600, 500));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
);
|
||||
expect(
|
||||
container.read(tacticalControllerProvider).selectedUnitIds,
|
||||
isEmpty,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('view-menu')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Hide Drones'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final gesture = await tester.startGesture(const Offset(660, 520));
|
||||
await gesture.moveTo(const Offset(960, 745));
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
container.read(tacticalControllerProvider).selectedUnitIds,
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('follows a dark system preference', (tester) async {
|
||||
tester.binding.platformDispatcher.platformBrightnessTestValue =
|
||||
Brightness.dark;
|
||||
addTearDown(
|
||||
tester.binding.platformDispatcher.clearPlatformBrightnessTestValue,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final context = tester.element(find.byType(AppShell));
|
||||
expect(Theme.of(context).brightness, Brightness.dark);
|
||||
});
|
||||
|
||||
testWidgets('large desktop text uses the compact session strip', (
|
||||
testWidgets('selection cursor ends pencil mode and restores selection', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1280, 900));
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('cursor-menu')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Pencil cursor'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('PENCIL CURSOR'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('SELECTION CURSOR'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('PENCIL CURSOR'), findsNothing);
|
||||
expect(FocusManager.instance.primaryFocus, isNotNull);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('hangar selection prepares a local unit before confirmation', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('units-menu')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Add local unit'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byKey(const Key('hangar-card-kaptan.vehicle.quadcopter.local')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final scope = ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
);
|
||||
expect(scope.read(tacticalControllerProvider).unitDraft, isNotNull);
|
||||
expect(scope.read(vehicleWorkspaceProvider).units, isEmpty);
|
||||
|
||||
await tester.tap(find.text('PREPARE UNIT'));
|
||||
await tester.pumpAndSettle();
|
||||
final name = find.byKey(const Key('local-unit-name-field'));
|
||||
await tester.tap(name);
|
||||
await tester.enterText(name, 'North survey');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('ADD TO LOCAL SCENE'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('North survey'), findsAtLeastNWidgets(1));
|
||||
expect(scope.read(vehicleWorkspaceProvider).units, hasLength(1));
|
||||
expect(
|
||||
scope.read(vehicleWorkspaceProvider).units.single.displayName,
|
||||
'North survey',
|
||||
);
|
||||
expect(
|
||||
ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
).read(tacticalControllerProvider).mode,
|
||||
isA<TacticalBrowseMode>(),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('hangar keeps a draft on back and blocks an invalid local name', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('units-menu')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Add local unit'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byKey(const Key('hangar-card-kaptan.vehicle.fixed-wing.local')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('PREPARE UNIT'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final name = find.byKey(const Key('local-unit-name-field'));
|
||||
await tester.tap(name);
|
||||
await tester.enterText(name, ' ');
|
||||
await tester.tap(find.text('ADD TO LOCAL SCENE'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text('Enter a local unit name of up to 32 characters.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
final scope = ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
);
|
||||
expect(scope.read(vehicleWorkspaceProvider).units, isEmpty);
|
||||
|
||||
await tester.tap(find.text('BACK TO HANGAR'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('FIXED-WING SELECTED'), findsOneWidget);
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).unitDraft?.definition.name,
|
||||
'Fixed-wing',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('hangar supports arrow selection and Escape focus return', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('units-menu')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Add local unit'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final scope = ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
);
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).unitDraft?.definition.name,
|
||||
'Fixed-wing',
|
||||
);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('UNIT HANGAR'), findsNothing);
|
||||
final trigger = find.descendant(
|
||||
of: find.byKey(const Key('units-menu')),
|
||||
matching: find.byType(TextButton),
|
||||
);
|
||||
expect(tester.widget<TextButton>(trigger).focusNode?.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('hangar and setup remain usable at 200 percent text scale', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1440, 960));
|
||||
tester.binding.platformDispatcher.textScaleFactorTestValue = 2;
|
||||
addTearDown(() {
|
||||
tester.binding.setSurfaceSize(null);
|
||||
tester.binding.platformDispatcher.clearTextScaleFactorTestValue();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp()));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('OBSERVATION ONLY'), findsOneWidget);
|
||||
expect(find.text('Replay ended 12:00:06Z'), findsNothing);
|
||||
final scope = ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
);
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
controller.showUnitHangar();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('unit-hangar-strip')), findsOneWidget);
|
||||
controller.chooseUnitDefinition(
|
||||
supportedVehicleDefinitions.first,
|
||||
displayName: 'QUADCOPTER-01',
|
||||
);
|
||||
controller.beginUnitSetup();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('UNIT SETUP'), findsOneWidget);
|
||||
expect(find.byKey(const Key('local-unit-name-field')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('hangar stays available in the short desktop arrangement', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 760));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final scope = ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
);
|
||||
scope.read(tacticalControllerProvider.notifier).showUnitHangar();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('UNIT HANGAR'), findsOneWidget);
|
||||
expect(find.byKey(const Key('unit-hangar-strip')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('secondary click cancels the pencil cursor', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('cursor-menu')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Pencil cursor'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('PENCIL CURSOR'), findsOneWidget);
|
||||
|
||||
final pointer = TestPointer(42, PointerDeviceKind.mouse);
|
||||
await tester.sendEventToBinding(
|
||||
pointer.down(const Offset(700, 520), buttons: kSecondaryButton),
|
||||
);
|
||||
await tester.sendEventToBinding(pointer.up());
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('PENCIL CURSOR'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('keyboard pencil keeps a visible draft before finishing', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('cursor-menu')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Pencil cursor'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
for (var index = 0; index < 3; index++) {
|
||||
await tester.tap(find.text('ADD LOCAL POINT'));
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('LOCAL POINTS: 3'), findsOneWidget);
|
||||
expect(find.text('FINISH DRAWING'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('FINISH DRAWING'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('PENCIL CURSOR'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('compact command dashboard remains usable without overflow', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(480, 800));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('SIMULATED CAMERA CONCEPT'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('command dashboard handles 200 percent text scale', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1440, 960));
|
||||
tester.binding.platformDispatcher.textScaleFactorTestValue = 2;
|
||||
addTearDown(() {
|
||||
tester.binding.setSurfaceSize(null);
|
||||
tester.binding.platformDispatcher.clearTextScaleFactorTestValue();
|
||||
});
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'LOCAL SCENE DATA · NO VEHICLE LINK · COMMAND AUTHORITY UNAVAILABLE',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('pencil toolbar reflows at 200 percent text scale', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1440, 960));
|
||||
tester.binding.platformDispatcher.textScaleFactorTestValue = 2;
|
||||
addTearDown(() {
|
||||
tester.binding.setSurfaceSize(null);
|
||||
tester.binding.platformDispatcher.clearTextScaleFactorTestValue();
|
||||
});
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
ProviderScope.containerOf(
|
||||
tester.element(find.byType(TacticalCommandPage)),
|
||||
).read(tacticalControllerProvider.notifier).beginPencil();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('PENCIL CURSOR'), findsOneWidget);
|
||||
expect(find.byKey(const Key('pencil-thickness')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('short desktop window uses the non-overlapping command layout', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1366, 768));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(const GroundSuiteApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('SIMULATED CAMERA CONCEPT'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user