feat(display): Sprint 4 — pantalla principal del autopilot
Archivos nuevos:
display/lib/data/autopilot_state.dart
- ChangeNotifier con todos los datos del autopilot
- Simulación de velero en mar (heading drift / P-controller)
- API pública estable: engage(), disengage(), adjustSetpoint(), selectMode()
- Sprint 7: los internos se reemplazan por Modbus RTU, la API no cambia
display/lib/screens/cockpit/cockpit_screen.dart
- Pantalla principal: TopBar, ModeSelector, CompassRose, DataStrip,
HeadingAdjustBar, RudderIndicator, ENGAGE/DISENGAGE
- Logo con triple-tap para ciclar temas (StateWidget con Timer)
- Indicador DEMO visible cuando isConnected == false
- Engranaje → AppearanceSettingsScreen
display/lib/widgets/themed/engage_button.dart
- Botón verde con glow; dimmed cuando ya está engaged
display/lib/widgets/themed/heading_adjust_bar.dart
- Botones << < [SET 048.0°] > >>
- Deshabilitado cuando mode == STANDBY
display/lib/widgets/themed/status_chip.dart
- Indicador de punto + label para NMEA / GPS (ok / warn / off)
Modificado:
display/lib/main.dart
- MultiProvider: agrega AutopilotState al árbol de providers
- Ruta inicial: CockpitScreen.routeName ('/') en lugar de Appearance
AR_electronics — AR-Autopilot Project
This commit is contained in:
@@ -0,0 +1,167 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// data/autopilot_state.dart — Live autopilot data model
|
||||||
|
// =============================================================================
|
||||||
|
//
|
||||||
|
// Sprint 4: simulated demo data (vessel drifting / heading-hold correction).
|
||||||
|
// Sprint 7: internals replaced by Modbus RTU over USB serial.
|
||||||
|
// The public API (fields + methods) must not change between sprints.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../widgets/themed/mode_selector.dart';
|
||||||
|
|
||||||
|
/// Live state of the autopilot and navigation instruments.
|
||||||
|
///
|
||||||
|
/// Consumed by [CockpitScreen] via `context.watch<AutopilotState>()`.
|
||||||
|
///
|
||||||
|
/// ## Data sources (Sprint 4 → Sprint 7)
|
||||||
|
/// Sprint 4: internal [Timer]-driven demo that simulates a vessel at sea.
|
||||||
|
/// Sprint 7: [AutopilotStateModbus] subclass (or internal swap) feeds real
|
||||||
|
/// Modbus RTU data received over USB serial from the AR-Concentrador.
|
||||||
|
class AutopilotState extends ChangeNotifier {
|
||||||
|
// ── Navigation data ──────────────────────────────────────────────────────────
|
||||||
|
/// Current vessel heading from NMEA 2000 backbone (degrees, magnetic, 0–359.9).
|
||||||
|
double headingDeg = 125.0;
|
||||||
|
|
||||||
|
/// Operator-commanded heading setpoint sent to autopilot (degrees, magnetic).
|
||||||
|
double setpointDeg = 125.0;
|
||||||
|
|
||||||
|
/// Rudder angle in degrees. Negative = port, positive = starboard.
|
||||||
|
double rudderDeg = 0.0;
|
||||||
|
|
||||||
|
/// Speed over ground (knots).
|
||||||
|
double sogKn = 6.2;
|
||||||
|
|
||||||
|
/// Course over ground (degrees, true).
|
||||||
|
double cogDeg = 127.0;
|
||||||
|
|
||||||
|
/// Rate of turn (degrees per minute). Positive = turning starboard.
|
||||||
|
double rotDpm = 0.0;
|
||||||
|
|
||||||
|
/// Water depth below keel (metres).
|
||||||
|
double depthM = 42.5;
|
||||||
|
|
||||||
|
// ── Autopilot state ──────────────────────────────────────────────────────────
|
||||||
|
/// Current autopilot mode as reported by the concentrador $PARP,STATUS.
|
||||||
|
AutopilotMode mode = AutopilotMode.standby;
|
||||||
|
|
||||||
|
/// True when the Modbus RTU link to the concentrador is active.
|
||||||
|
/// Sprint 4: always false (demo mode label shown in UI).
|
||||||
|
bool isConnected = false;
|
||||||
|
|
||||||
|
// ── Internal ─────────────────────────────────────────────────────────────────
|
||||||
|
Timer? _demoTimer;
|
||||||
|
final _rng = math.Random();
|
||||||
|
|
||||||
|
AutopilotState() {
|
||||||
|
_startDemo();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Demo simulation (Sprint 4 only)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void _startDemo() {
|
||||||
|
_demoTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||||
|
_tick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _tick() {
|
||||||
|
switch (mode) {
|
||||||
|
case AutopilotMode.standby:
|
||||||
|
// Vessel drifts slightly — small random walk on heading and rudder.
|
||||||
|
headingDeg = (headingDeg + (_rng.nextDouble() - 0.5) * 0.5) % 360;
|
||||||
|
if (headingDeg < 0) headingDeg += 360;
|
||||||
|
rudderDeg = (rudderDeg + (_rng.nextDouble() - 0.5) * 0.8)
|
||||||
|
.clamp(-5.0, 5.0);
|
||||||
|
rotDpm = rudderDeg * 0.3 + (_rng.nextDouble() - 0.5) * 0.2;
|
||||||
|
|
||||||
|
case AutopilotMode.headingHold:
|
||||||
|
// Simulated P-controller: autopilot drives rudder toward setpoint.
|
||||||
|
final error = _angleDiff(setpointDeg, headingDeg);
|
||||||
|
rudderDeg = (error * 1.2 + (_rng.nextDouble() - 0.5) * 0.5)
|
||||||
|
.clamp(-35.0, 35.0);
|
||||||
|
headingDeg =
|
||||||
|
(headingDeg + error * 0.025 + (_rng.nextDouble() - 0.5) * 0.08)
|
||||||
|
% 360;
|
||||||
|
if (headingDeg < 0) headingDeg += 360;
|
||||||
|
rotDpm = error * 0.4;
|
||||||
|
|
||||||
|
case AutopilotMode.trackKeep:
|
||||||
|
// Sprint 6: identical to headingHold for demo purposes.
|
||||||
|
final error = _angleDiff(setpointDeg, headingDeg);
|
||||||
|
rudderDeg =
|
||||||
|
(error * 1.2).clamp(-35.0, 35.0);
|
||||||
|
headingDeg = (headingDeg + error * 0.025) % 360;
|
||||||
|
if (headingDeg < 0) headingDeg += 360;
|
||||||
|
rotDpm = error * 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// COG tracks heading with a small lag.
|
||||||
|
cogDeg = (headingDeg + rudderDeg * 0.15 + (_rng.nextDouble() - 0.5) * 0.3)
|
||||||
|
% 360;
|
||||||
|
if (cogDeg < 0) cogDeg += 360;
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signed difference in [−180, +180]: target − current.
|
||||||
|
double _angleDiff(double target, double current) {
|
||||||
|
double d = (target - current) % 360;
|
||||||
|
if (d > 180) d -= 360;
|
||||||
|
if (d < -180) d += 360;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Commands — called by UI; Sprint 7 will also send Modbus frames here.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Engage heading hold on the current heading.
|
||||||
|
void engage() {
|
||||||
|
setpointDeg = headingDeg;
|
||||||
|
mode = AutopilotMode.headingHold;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return to STANDBY (manual helm).
|
||||||
|
void disengage() {
|
||||||
|
mode = AutopilotMode.standby;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjust the heading setpoint while in heading-hold mode.
|
||||||
|
/// No-op in STANDBY or TRACK.
|
||||||
|
void adjustSetpoint(double deltaDeg) {
|
||||||
|
if (mode != AutopilotMode.headingHold) return;
|
||||||
|
setpointDeg = (setpointDeg + deltaDeg) % 360;
|
||||||
|
if (setpointDeg < 0) setpointDeg += 360;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle ModeSelector taps.
|
||||||
|
void selectMode(AutopilotMode newMode) {
|
||||||
|
switch (newMode) {
|
||||||
|
case AutopilotMode.standby:
|
||||||
|
disengage();
|
||||||
|
case AutopilotMode.headingHold:
|
||||||
|
engage();
|
||||||
|
case AutopilotMode.trackKeep:
|
||||||
|
// Sprint 6: engage track-keep mode (requires GPS cross-track error).
|
||||||
|
mode = AutopilotMode.trackKeep;
|
||||||
|
setpointDeg = headingDeg;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_demoTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-5
@@ -1,15 +1,27 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'data/autopilot_state.dart';
|
||||||
import 'theme/theme_provider.dart';
|
import 'theme/theme_provider.dart';
|
||||||
|
import 'screens/cockpit/cockpit_screen.dart';
|
||||||
import 'screens/settings/appearance_settings.dart';
|
import 'screens/settings/appearance_settings.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
final themeProvider = await AutopilotThemeProvider.load();
|
final themeProvider = await AutopilotThemeProvider.load();
|
||||||
runApp(
|
runApp(
|
||||||
ChangeNotifierProvider<AutopilotThemeProvider>.value(
|
MultiProvider(
|
||||||
value: themeProvider,
|
providers: [
|
||||||
|
ChangeNotifierProvider<AutopilotThemeProvider>.value(
|
||||||
|
value: themeProvider,
|
||||||
|
),
|
||||||
|
// AutopilotState drives the cockpit UI.
|
||||||
|
// Sprint 4: internal demo timer (vessel simulation).
|
||||||
|
// Sprint 7: replace with AutopilotStateModbus or wire Modbus RTU here.
|
||||||
|
ChangeNotifierProvider<AutopilotState>(
|
||||||
|
create: (_) => AutopilotState(),
|
||||||
|
),
|
||||||
|
],
|
||||||
child: const ArAutopilotApp(),
|
child: const ArAutopilotApp(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -24,10 +36,11 @@ class ArAutopilotApp extends StatelessWidget {
|
|||||||
title: 'AR-Autopilot',
|
title: 'AR-Autopilot',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ThemeData(useMaterial3: true),
|
theme: ThemeData(useMaterial3: true),
|
||||||
// Initial route — Sprint 4 starts with the Appearance screen for demo
|
initialRoute: CockpitScreen.routeName,
|
||||||
initialRoute: AppearanceSettingsScreen.routeName,
|
|
||||||
routes: {
|
routes: {
|
||||||
AppearanceSettingsScreen.routeName: (_) => const AppearanceSettingsScreen(),
|
CockpitScreen.routeName: (_) => const CockpitScreen(),
|
||||||
|
AppearanceSettingsScreen.routeName: (_) =>
|
||||||
|
const AppearanceSettingsScreen(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// screens/cockpit/cockpit_screen.dart — AR-Autopilot main cockpit view
|
||||||
|
// =============================================================================
|
||||||
|
//
|
||||||
|
// Sprint 4: static layout with demo data (AutopilotState simulation).
|
||||||
|
// Sprint 7: AutopilotState internals replaced by Modbus RTU over USB serial.
|
||||||
|
//
|
||||||
|
// Layout (top → bottom):
|
||||||
|
// TopBar — logo, title, NMEA/GPS status, settings gear
|
||||||
|
// ModeSelector — STANDBY | HDG HOLD | TRACK
|
||||||
|
// CompassRose — dominant visual; shows heading + setpoint tick
|
||||||
|
// DataStrip — SOG · COG · ROT · DEPTH
|
||||||
|
// HeadingAdjust — << < [SET 048°] > >>
|
||||||
|
// RudderRow — label + horizontal rudder indicator
|
||||||
|
// ActionRow — [ ENGAGE ] [ DISENGAGE ]
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../data/autopilot_state.dart';
|
||||||
|
import '../../theme/autopilot_theme.dart';
|
||||||
|
import '../../theme/theme_provider.dart';
|
||||||
|
import '../../widgets/themed/compass_rose.dart';
|
||||||
|
import '../../widgets/themed/disengage_button.dart';
|
||||||
|
import '../../widgets/themed/engage_button.dart';
|
||||||
|
import '../../widgets/themed/heading_adjust_bar.dart';
|
||||||
|
import '../../widgets/themed/mode_selector.dart';
|
||||||
|
import '../../widgets/themed/rudder_indicator.dart';
|
||||||
|
import '../../widgets/themed/status_chip.dart';
|
||||||
|
import '../settings/appearance_settings.dart';
|
||||||
|
|
||||||
|
class CockpitScreen extends StatelessWidget {
|
||||||
|
const CockpitScreen({super.key});
|
||||||
|
|
||||||
|
static const String routeName = '/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = context.watch<AutopilotThemeProvider>().current;
|
||||||
|
final ap = context.watch<AutopilotState>();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
body: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 400),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
decoration: theme.backgroundDecoration,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_TopBar(theme: theme, ap: ap),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
|
||||||
|
child: ModeSelector(
|
||||||
|
activeMode: ap.mode,
|
||||||
|
onModeSelected: ap.selectMode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _CockpitBody(theme: theme, ap: ap),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Top bar ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _TopBar extends StatelessWidget {
|
||||||
|
const _TopBar({required this.theme, required this.ap});
|
||||||
|
|
||||||
|
final AutopilotTheme theme;
|
||||||
|
final AutopilotState ap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 54,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.backgroundMid.withValues(alpha: 0.9),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(color: theme.panelBorder, width: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Logo — triple-tap cycles themes (design invariant from Sprint 3)
|
||||||
|
_ThemeCycleLogo(theme: theme),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'AR-AUTOPILOT',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.textMain,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 1.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!ap.isConnected) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.warnColor.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.warnColor.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'DEMO',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.warnColor,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const Spacer(),
|
||||||
|
StatusChip(
|
||||||
|
theme: theme,
|
||||||
|
label: 'NMEA',
|
||||||
|
status: ap.isConnected ? StatusLevel.ok : StatusLevel.off,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
StatusChip(
|
||||||
|
theme: theme,
|
||||||
|
label: 'GPS',
|
||||||
|
status: ap.isConnected ? StatusLevel.ok : StatusLevel.warn,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Navigator.pushNamed(
|
||||||
|
context, AppearanceSettingsScreen.routeName),
|
||||||
|
child: Icon(
|
||||||
|
Icons.settings_outlined,
|
||||||
|
color: theme.textMuted,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AR Electronics logo that cycles themes on triple-tap.
|
||||||
|
///
|
||||||
|
/// Counts taps within 600 ms; three rapid taps rotate through the 4 themes.
|
||||||
|
/// This implements the shortcut described in [AppearanceSettingsScreen].
|
||||||
|
class _ThemeCycleLogo extends StatefulWidget {
|
||||||
|
const _ThemeCycleLogo({required this.theme});
|
||||||
|
|
||||||
|
final AutopilotTheme theme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ThemeCycleLogo> createState() => _ThemeCycleLogoState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ThemeCycleLogoState extends State<_ThemeCycleLogo> {
|
||||||
|
static const _kWindow = Duration(milliseconds: 600);
|
||||||
|
static const _kIds = ['light', 'cyan', 'wine', 'ochre'];
|
||||||
|
|
||||||
|
int _taps = 0;
|
||||||
|
Timer? _resetTimer;
|
||||||
|
|
||||||
|
void _onTap() {
|
||||||
|
_resetTimer?.cancel();
|
||||||
|
_taps++;
|
||||||
|
if (_taps >= 3) {
|
||||||
|
_taps = 0;
|
||||||
|
final provider = context.read<AutopilotThemeProvider>();
|
||||||
|
final idx = _kIds.indexOf(provider.current.id);
|
||||||
|
provider.setTheme(_kIds[(idx + 1) % _kIds.length]);
|
||||||
|
} else {
|
||||||
|
_resetTimer = Timer(_kWindow, () => _taps = 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_resetTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _onTap,
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/images/ar_logo_full.png',
|
||||||
|
height: 34,
|
||||||
|
errorBuilder: (_, __, ___) => Icon(
|
||||||
|
Icons.anchor,
|
||||||
|
color: widget.theme.accentMid,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cockpit body ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _CockpitBody extends StatelessWidget {
|
||||||
|
const _CockpitBody({required this.theme, required this.ap});
|
||||||
|
|
||||||
|
final AutopilotTheme theme;
|
||||||
|
final AutopilotState ap;
|
||||||
|
|
||||||
|
bool get _engaged => ap.mode != AutopilotMode.standby;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// Compass scales between 200 and 320 px based on available width.
|
||||||
|
final compassSize =
|
||||||
|
(constraints.maxWidth * 0.68).clamp(200.0, 320.0);
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// ── Compass rose ─────────────────────────────────────────────
|
||||||
|
Center(
|
||||||
|
child: CompassRose(
|
||||||
|
headingDeg: ap.headingDeg,
|
||||||
|
setPointDeg: _engaged ? ap.setpointDeg : null,
|
||||||
|
size: compassSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
|
// ── Instrument data strip ────────────────────────────────────
|
||||||
|
_DataStrip(theme: theme, ap: ap),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
|
// ── Heading setpoint + adjust buttons ────────────────────────
|
||||||
|
HeadingAdjustBar(
|
||||||
|
setpointDeg: ap.setpointDeg,
|
||||||
|
enabled: _engaged,
|
||||||
|
onAdjust: ap.adjustSetpoint,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
|
// ── Rudder indicator ─────────────────────────────────────────
|
||||||
|
_RudderRow(theme: theme, rudderDeg: ap.rudderDeg),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// ── ENGAGE / DISENGAGE row ───────────────────────────────────
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: EngageButton(
|
||||||
|
onPressed: !_engaged ? ap.engage : null,
|
||||||
|
enabled: !_engaged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: DisengageButton(
|
||||||
|
onPressed: _engaged ? ap.disengage : null,
|
||||||
|
enabled: _engaged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data strip ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _DataStrip extends StatelessWidget {
|
||||||
|
const _DataStrip({required this.theme, required this.ap});
|
||||||
|
|
||||||
|
final AutopilotTheme theme;
|
||||||
|
final AutopilotState ap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: theme.panelBackground,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: theme.panelBorder),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
_DataCell(
|
||||||
|
theme: theme,
|
||||||
|
label: 'SOG',
|
||||||
|
value: '${ap.sogKn.toStringAsFixed(1)} kn',
|
||||||
|
),
|
||||||
|
_VerticalDivider(theme: theme),
|
||||||
|
_DataCell(
|
||||||
|
theme: theme,
|
||||||
|
label: 'COG',
|
||||||
|
value: '${ap.cogDeg.toStringAsFixed(0).padLeft(3, '0')}°',
|
||||||
|
),
|
||||||
|
_VerticalDivider(theme: theme),
|
||||||
|
_DataCell(
|
||||||
|
theme: theme,
|
||||||
|
label: 'ROT',
|
||||||
|
value: '${ap.rotDpm.toStringAsFixed(1)}°/m',
|
||||||
|
),
|
||||||
|
_VerticalDivider(theme: theme),
|
||||||
|
_DataCell(
|
||||||
|
theme: theme,
|
||||||
|
label: 'PROF',
|
||||||
|
value: '${ap.depthM.toStringAsFixed(1)} m',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VerticalDivider extends StatelessWidget {
|
||||||
|
const _VerticalDivider({required this.theme});
|
||||||
|
|
||||||
|
final AutopilotTheme theme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) =>
|
||||||
|
Container(width: 1, height: 30, color: theme.panelBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DataCell extends StatelessWidget {
|
||||||
|
const _DataCell({
|
||||||
|
required this.theme,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AutopilotTheme theme;
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.textMuted,
|
||||||
|
fontSize: 9,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.textMain,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rudder row ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _RudderRow extends StatelessWidget {
|
||||||
|
const _RudderRow({required this.theme, required this.rudderDeg});
|
||||||
|
|
||||||
|
final AutopilotTheme theme;
|
||||||
|
final double rudderDeg;
|
||||||
|
|
||||||
|
String _label(double deg) {
|
||||||
|
if (deg.abs() < 0.5) return 'CENTRO';
|
||||||
|
final side = deg < 0 ? 'BABOR' : 'ESTRIBOR';
|
||||||
|
return '${deg.abs().toStringAsFixed(1)}° $side';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'TIMÓN',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.textMuted,
|
||||||
|
fontSize: 9,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
_label(rudderDeg),
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.textSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
RudderIndicator(rudderDeg: rudderDeg),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../theme/autopilot_theme.dart';
|
||||||
|
import '../../theme/theme_provider.dart';
|
||||||
|
|
||||||
|
/// ENGAGE button — activates heading-hold at the current vessel heading.
|
||||||
|
///
|
||||||
|
/// Shown enabled (green, glowing) only when autopilot is in STANDBY.
|
||||||
|
/// When already engaged, [enabled] is false and the button dims.
|
||||||
|
///
|
||||||
|
/// Minimum touch target: 60×60 px (critical control, see design invariant §6).
|
||||||
|
class EngageButton extends StatefulWidget {
|
||||||
|
const EngageButton({
|
||||||
|
super.key,
|
||||||
|
required this.onPressed,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EngageButton> createState() => _EngageButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EngageButtonState extends State<EngageButton> {
|
||||||
|
bool _pressed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = context.watch<AutopilotThemeProvider>().current;
|
||||||
|
final enabled = widget.enabled && widget.onPressed != null;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTapDown: enabled ? (_) => setState(() => _pressed = true) : null,
|
||||||
|
onTapUp: enabled
|
||||||
|
? (_) {
|
||||||
|
setState(() => _pressed = false);
|
||||||
|
widget.onPressed!();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onTapCancel: () => setState(() => _pressed = false),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
constraints: const BoxConstraints(minWidth: 60, minHeight: 60),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: enabled
|
||||||
|
? LinearGradient(
|
||||||
|
colors: [
|
||||||
|
theme.okColor.withValues(alpha: 0.65),
|
||||||
|
theme.okColor.withValues(alpha: 0.35),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
)
|
||||||
|
: LinearGradient(
|
||||||
|
colors: [theme.panelBorder, theme.panelBorder],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: enabled ? theme.okColor : theme.panelBorder,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
boxShadow: enabled
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: theme.okColor
|
||||||
|
.withValues(alpha: _pressed ? 0.2 : 0.45),
|
||||||
|
blurRadius: _pressed ? 4 : 14,
|
||||||
|
spreadRadius: _pressed ? 0 : 2,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'ENGAGE',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: enabled ? Colors.white : theme.textDisabled,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
shadows: enabled
|
||||||
|
? [Shadow(color: theme.okColor, blurRadius: 4)]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../theme/autopilot_theme.dart';
|
||||||
|
import '../../theme/theme_provider.dart';
|
||||||
|
|
||||||
|
/// Four heading-adjust buttons around a central setpoint readout.
|
||||||
|
///
|
||||||
|
/// Layout (left → right):
|
||||||
|
/// [ << 10° ] [ < 1° ] SET 048.0° [ 1° > ] [ 10° >> ]
|
||||||
|
///
|
||||||
|
/// All buttons are disabled when [enabled] is false (autopilot not engaged).
|
||||||
|
/// The setpoint display dims when disabled to reinforce the inactive state.
|
||||||
|
///
|
||||||
|
/// [onAdjust] receives the signed delta in degrees (+1, −1, +10, −10).
|
||||||
|
class HeadingAdjustBar extends StatelessWidget {
|
||||||
|
const HeadingAdjustBar({
|
||||||
|
super.key,
|
||||||
|
required this.setpointDeg,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onAdjust,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double setpointDeg;
|
||||||
|
final bool enabled;
|
||||||
|
final ValueChanged<double> onAdjust;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = context.watch<AutopilotThemeProvider>().current;
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
_AdjustButton(
|
||||||
|
theme: theme, label: '<<\n10°', delta: -10,
|
||||||
|
enabled: enabled, onAdjust: onAdjust),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_AdjustButton(
|
||||||
|
theme: theme, label: '<\n1°', delta: -1,
|
||||||
|
enabled: enabled, onAdjust: onAdjust),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _SetpointDisplay(
|
||||||
|
theme: theme,
|
||||||
|
setpointDeg: setpointDeg,
|
||||||
|
enabled: enabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_AdjustButton(
|
||||||
|
theme: theme, label: '1°\n>', delta: 1,
|
||||||
|
enabled: enabled, onAdjust: onAdjust),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_AdjustButton(
|
||||||
|
theme: theme, label: '10°\n>>', delta: 10,
|
||||||
|
enabled: enabled, onAdjust: onAdjust),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setpoint display ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _SetpointDisplay extends StatelessWidget {
|
||||||
|
const _SetpointDisplay({
|
||||||
|
required this.theme,
|
||||||
|
required this.setpointDeg,
|
||||||
|
required this.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AutopilotTheme theme;
|
||||||
|
final double setpointDeg;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 52,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: theme.panelBackground,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(
|
||||||
|
color: enabled
|
||||||
|
? theme.setLight.withValues(alpha: 0.5)
|
||||||
|
: theme.panelBorder,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'SET',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.textMuted,
|
||||||
|
fontSize: 9,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${setpointDeg.toStringAsFixed(1)}°',
|
||||||
|
style: TextStyle(
|
||||||
|
color: enabled ? theme.setLight : theme.textDisabled,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
shadows: enabled && theme.accentGlowRadius > 0
|
||||||
|
? [Shadow(color: theme.setGlow, blurRadius: 8)]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Adjust button ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _AdjustButton extends StatefulWidget {
|
||||||
|
const _AdjustButton({
|
||||||
|
required this.theme,
|
||||||
|
required this.label,
|
||||||
|
required this.delta,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onAdjust,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AutopilotTheme theme;
|
||||||
|
final String label;
|
||||||
|
final double delta;
|
||||||
|
final bool enabled;
|
||||||
|
final ValueChanged<double> onAdjust;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AdjustButton> createState() => _AdjustButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdjustButtonState extends State<_AdjustButton> {
|
||||||
|
bool _pressed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = widget.theme;
|
||||||
|
final enabled = widget.enabled;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTapDown: enabled ? (_) => setState(() => _pressed = true) : null,
|
||||||
|
onTapUp: enabled
|
||||||
|
? (_) {
|
||||||
|
setState(() => _pressed = false);
|
||||||
|
widget.onAdjust(widget.delta);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onTapCancel: () => setState(() => _pressed = false),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
constraints: const BoxConstraints(minWidth: 48, minHeight: 52),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: enabled ? t.actionButtonBackground : null,
|
||||||
|
color: enabled ? null : t.backgroundDeep,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(
|
||||||
|
color: enabled && !_pressed
|
||||||
|
? t.actionButtonBorder
|
||||||
|
: t.panelBorder,
|
||||||
|
),
|
||||||
|
boxShadow: enabled && !_pressed
|
||||||
|
? t.glowShadow(t.actionButtonGlow, t.accentGlowRadius / 2)
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
widget.label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: enabled ? t.actionButtonText : t.textDisabled,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.35,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../theme/autopilot_theme.dart';
|
||||||
|
|
||||||
|
/// Connection / data-source status indicator — coloured dot + label.
|
||||||
|
///
|
||||||
|
/// Used in the [CockpitScreen] top-bar to show NMEA and GPS link state.
|
||||||
|
enum StatusLevel {
|
||||||
|
/// Data valid and link active.
|
||||||
|
ok,
|
||||||
|
|
||||||
|
/// Data stale, link degraded, or GPS fix lost.
|
||||||
|
warn,
|
||||||
|
|
||||||
|
/// Link absent (serial port not open, concentrador not connected).
|
||||||
|
off,
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatusChip extends StatelessWidget {
|
||||||
|
const StatusChip({
|
||||||
|
super.key,
|
||||||
|
required this.theme,
|
||||||
|
required this.label,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AutopilotTheme theme;
|
||||||
|
final String label;
|
||||||
|
final StatusLevel status;
|
||||||
|
|
||||||
|
Color get _dotColor => switch (status) {
|
||||||
|
StatusLevel.ok => theme.okColor,
|
||||||
|
StatusLevel.warn => theme.warnColor,
|
||||||
|
StatusLevel.off => theme.textDisabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final dot = _dotColor;
|
||||||
|
final glowing = status == StatusLevel.ok && theme.accentGlowRadius > 0;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: dot,
|
||||||
|
boxShadow: glowing
|
||||||
|
? [BoxShadow(color: dot.withValues(alpha: 0.6), blurRadius: 6)]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
status == StatusLevel.off ? theme.textDisabled : theme.textMuted,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user