diff --git a/.gitignore b/.gitignore index 181d4a7..7bf7b7e 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,8 @@ scripts/*.pyo *.pyc *.pyo __pycache__/ +.venv/ +uv.lock # extra resources diff --git a/pi/ui/lib/screens/dashboard_screen.dart b/pi/ui/lib/screens/dashboard_screen.dart index 561f8be..bd78d75 100644 --- a/pi/ui/lib/screens/dashboard_screen.dart +++ b/pi/ui/lib/screens/dashboard_screen.dart @@ -191,8 +191,8 @@ class _DashboardScreenState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - StatBox(value: _formatInt(_rpm), label: 'RPM'), - StatBox(value: _formatInt(_engineTemp), unit: '°C', label: 'ENG'), + StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000), + StatBox(value: _formatInt(_engineTemp), unit: '°C', label: 'ENG', isWarning: () => (_engineTemp ?? 0) > 120), StatBox(value: _formatGear(_gear), label: 'GEAR'), ], ), diff --git a/pi/ui/lib/widgets/navigator_widget.dart b/pi/ui/lib/widgets/navigator_widget.dart index 263ee75..6d296fb 100644 --- a/pi/ui/lib/widgets/navigator_widget.dart +++ b/pi/ui/lib/widgets/navigator_widget.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math'; import 'package:flutter/material.dart'; import '../services/config_service.dart'; @@ -18,14 +19,34 @@ class NavigatorWidget extends StatefulWidget { State createState() => NavigatorWidgetState(); } -class NavigatorWidgetState extends State { +class NavigatorWidgetState extends State + with SingleTickerProviderStateMixin { String _emotion = 'default'; + late AnimationController _shakeController; + + @override + void initState() { + super.initState(); + _shakeController = AnimationController( + duration: const Duration(milliseconds: 400), + vsync: this, + ); + } + + @override + void dispose() { + _shakeController.dispose(); + super.dispose(); + } /// Change the displayed emotion. /// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png void setEmotion(String emotion) { if (emotion != _emotion) { setState(() => _emotion = emotion); + if (emotion == 'surprise') { + _shakeController.forward(from: 0); + } } } @@ -40,7 +61,7 @@ class NavigatorWidgetState extends State { final config = ConfigService.instance; final basePath = '${config.assetsPath}/navigator/${config.navigator}'; - return Image.file( + final image = Image.file( File('$basePath/$_emotion.png'), fit: BoxFit.contain, errorBuilder: (context, error, stackTrace) { @@ -55,5 +76,19 @@ class NavigatorWidgetState extends State { return const SizedBox.shrink(); }, ); + + // Shake animation for surprise + return AnimatedBuilder( + animation: _shakeController, + child: image, + builder: (context, child) { + final shake = sin(_shakeController.value * pi * 6) * 10 * + (1 - _shakeController.value); // 6 oscillations, 4px amplitude, decay + return Transform.translate( + offset: Offset(shake, 0), + child: child, + ); + }, + ); } } diff --git a/pi/ui/lib/widgets/stat_box.dart b/pi/ui/lib/widgets/stat_box.dart index de57787..a054804 100644 --- a/pi/ui/lib/widgets/stat_box.dart +++ b/pi/ui/lib/widgets/stat_box.dart @@ -9,17 +9,23 @@ class StatBox extends StatelessWidget { final String label; final int flex; + /// Optional warning predicate - if returns true, value shows in highlight color + final bool Function()? isWarning; + const StatBox({ super.key, required this.value, this.unit, required this.label, this.flex = 1, + this.isWarning, }); @override Widget build(BuildContext context) { final theme = AppTheme.of(context); + final warning = isWarning?.call() ?? false; + final valueColor = warning ? theme.highlight : theme.foreground; return Expanded( flex: flex, @@ -42,7 +48,7 @@ class StatBox extends StatelessWidget { fontSize: baseSize, fontWeight: FontWeight.w400, fontFeatures: const [FontFeature.tabularFigures()], - color: theme.foreground, + color: valueColor, height: 1, ), ), diff --git a/scripts/deploy_backend.py b/scripts/deploy_backend.py index 09702ed..02220ff 100644 --- a/scripts/deploy_backend.py +++ b/scripts/deploy_backend.py @@ -17,6 +17,7 @@ SCRIPT_DIR = Path(__file__).parent.resolve() PROJECT_ROOT = SCRIPT_DIR.parent CONFIG_FILE = SCRIPT_DIR / "deploy_target.json" BACKEND_DIR = PROJECT_ROOT / "pi" / "backend" +SERVICE_FILE = SCRIPT_DIR / "smartserow-backend.service" def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess: @@ -72,6 +73,7 @@ def deploy(restart: bool = False) -> bool: "--exclude", "*.pyc", "--exclude", ".venv", "--exclude", ".ruff_cache", + "--exclude", "uv.lock", # Let Pi generate its own lockfile f"{BACKEND_DIR}/", f"{ssh_target}:{remote_path}/", ]) @@ -88,6 +90,16 @@ def deploy(restart: bool = False) -> bool: print("WARNING: uv sync failed - dependencies may be out of date") print("Make sure uv is installed on Pi: curl -LsSf https://astral.sh/uv/install.sh | sh") + # Deploy service file if it exists + if SERVICE_FILE.exists(): + print() + print("Deploying systemd service file...") + run(["scp", str(SERVICE_FILE), f"{ssh_target}:/tmp/"]) + run([ + "ssh", ssh_target, + f"sudo mv /tmp/{SERVICE_FILE.name} /etc/systemd/system/ && sudo systemctl daemon-reload" + ]) + # Restart service if requested if restart: print()