backend deployment update and navigator shake animation

- backend: now runs uv sync at service start to make sure of uv lock status. might migrate to package/bundle
- navigator now shakes when entering 'surprise' state
This commit is contained in:
Mikkeli Matlock
2026-01-30 22:47:18 +09:00
parent 71e2214e32
commit 7a6e69861b
5 changed files with 60 additions and 5 deletions

2
.gitignore vendored
View File

@@ -65,6 +65,8 @@ scripts/*.pyo
*.pyc *.pyc
*.pyo *.pyo
__pycache__/ __pycache__/
.venv/
uv.lock
# extra resources # extra resources

View File

@@ -191,8 +191,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
StatBox(value: _formatInt(_rpm), label: 'RPM'), StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
StatBox(value: _formatInt(_engineTemp), unit: '°C', label: 'ENG'), StatBox(value: _formatInt(_engineTemp), unit: '°C', label: 'ENG', isWarning: () => (_engineTemp ?? 0) > 120),
StatBox(value: _formatGear(_gear), label: 'GEAR'), StatBox(value: _formatGear(_gear), label: 'GEAR'),
], ],
), ),

View File

@@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/config_service.dart'; import '../services/config_service.dart';
@@ -18,14 +19,34 @@ class NavigatorWidget extends StatefulWidget {
State<NavigatorWidget> createState() => NavigatorWidgetState(); State<NavigatorWidget> createState() => NavigatorWidgetState();
} }
class NavigatorWidgetState extends State<NavigatorWidget> { class NavigatorWidgetState extends State<NavigatorWidget>
with SingleTickerProviderStateMixin {
String _emotion = 'default'; 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. /// Change the displayed emotion.
/// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png /// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png
void setEmotion(String emotion) { void setEmotion(String emotion) {
if (emotion != _emotion) { if (emotion != _emotion) {
setState(() => _emotion = emotion); setState(() => _emotion = emotion);
if (emotion == 'surprise') {
_shakeController.forward(from: 0);
}
} }
} }
@@ -40,7 +61,7 @@ class NavigatorWidgetState extends State<NavigatorWidget> {
final config = ConfigService.instance; final config = ConfigService.instance;
final basePath = '${config.assetsPath}/navigator/${config.navigator}'; final basePath = '${config.assetsPath}/navigator/${config.navigator}';
return Image.file( final image = Image.file(
File('$basePath/$_emotion.png'), File('$basePath/$_emotion.png'),
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
@@ -55,5 +76,19 @@ class NavigatorWidgetState extends State<NavigatorWidget> {
return const SizedBox.shrink(); 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,
);
},
);
} }
} }

View File

@@ -9,17 +9,23 @@ class StatBox extends StatelessWidget {
final String label; final String label;
final int flex; final int flex;
/// Optional warning predicate - if returns true, value shows in highlight color
final bool Function()? isWarning;
const StatBox({ const StatBox({
super.key, super.key,
required this.value, required this.value,
this.unit, this.unit,
required this.label, required this.label,
this.flex = 1, this.flex = 1,
this.isWarning,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = AppTheme.of(context); final theme = AppTheme.of(context);
final warning = isWarning?.call() ?? false;
final valueColor = warning ? theme.highlight : theme.foreground;
return Expanded( return Expanded(
flex: flex, flex: flex,
@@ -42,7 +48,7 @@ class StatBox extends StatelessWidget {
fontSize: baseSize, fontSize: baseSize,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
fontFeatures: const [FontFeature.tabularFigures()], fontFeatures: const [FontFeature.tabularFigures()],
color: theme.foreground, color: valueColor,
height: 1, height: 1,
), ),
), ),

View File

@@ -17,6 +17,7 @@ SCRIPT_DIR = Path(__file__).parent.resolve()
PROJECT_ROOT = SCRIPT_DIR.parent PROJECT_ROOT = SCRIPT_DIR.parent
CONFIG_FILE = SCRIPT_DIR / "deploy_target.json" CONFIG_FILE = SCRIPT_DIR / "deploy_target.json"
BACKEND_DIR = PROJECT_ROOT / "pi" / "backend" BACKEND_DIR = PROJECT_ROOT / "pi" / "backend"
SERVICE_FILE = SCRIPT_DIR / "smartserow-backend.service"
def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess: def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess:
@@ -72,6 +73,7 @@ def deploy(restart: bool = False) -> bool:
"--exclude", "*.pyc", "--exclude", "*.pyc",
"--exclude", ".venv", "--exclude", ".venv",
"--exclude", ".ruff_cache", "--exclude", ".ruff_cache",
"--exclude", "uv.lock", # Let Pi generate its own lockfile
f"{BACKEND_DIR}/", f"{BACKEND_DIR}/",
f"{ssh_target}:{remote_path}/", 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("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") 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 # Restart service if requested
if restart: if restart:
print() print()