multiple updates

- colour theme implemented. ThemeService based global switching for future light detection triggers
- test flipflop service for various fun
- navigator widget class with state switching
This commit is contained in:
Mikkeli Matlock
2026-01-26 00:20:52 +09:00
parent b1e23fdd10
commit c7edc30b79
18 changed files with 489 additions and 67 deletions

View File

@@ -36,6 +36,17 @@ smart-serow/
└── LICENSE └── LICENSE
``` ```
## Theme System
The UI uses JSON-based themes for different navigator models.
- **Theme files**: `extra/themes/{navigator}.json` (e.g., `extra/themes/zumo.json`)
- **Generation**: `scripts/generate_theme.py` converts JSON → `pi/ui/lib/theme/app_colors.dart`
- **Auto-generation**: `build.py` runs theme generation before each Flutter build
- **Fallback chain**: Tries `{navigator}.json``default.json` → hardcoded defaults
To add a new theme, create `extra/themes/yourmodel.json` and set `"navigator": "yourmodel"` in `pi/ui/config.json`.
--- ---
## Build Environment Setup (WSL2) ## Build Environment Setup (WSL2)

14
extra/themes/default.json Normal file
View File

@@ -0,0 +1,14 @@
{
"dark": {
"background": "#000000",
"foreground": "#FFFFFF",
"highlight": "#FF5555",
"subdued": "#808080"
},
"bright": {
"background": "#F0F0F0",
"foreground": "#1A1A1A",
"highlight": "#CC0000",
"subdued": "#606060"
}
}

14
extra/themes/rei.json Normal file
View File

@@ -0,0 +1,14 @@
{
"dark": {
"background": "#101010",
"foreground": "#EAEAEA",
"highlight": "#FA1504",
"subdued": "#E47841"
},
"bright": {
"background": "#E47841",
"foreground": "#202020",
"highlight": "#F0F0F0",
"subdued": "#BC4600"
}
}

3
pi/ui/.gitignore vendored
View File

@@ -43,3 +43,6 @@ app.*.map.json
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
# Generated theme
lib/theme/app_colors.dart

View File

@@ -1,5 +1,5 @@
{ {
"assets_path": "/home/pi/smartserow-ui/assets", "assets_path": "/home/mikkeli/smartserow-ui/assets",
"navigator": "rei", "navigator": "rei",
"overheat": { "overheat": {
"threshold_celsius": 75.0, "threshold_celsius": 75.0,

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'app_root.dart'; import 'app_root.dart';
import 'theme/app_colors.dart';
import 'theme/app_theme.dart';
void main() { void main() {
runApp(const SmartSerowApp()); runApp(const SmartSerowApp());
@@ -11,18 +13,20 @@ class SmartSerowApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return AppThemeProvider(
title: 'Smart Serow', child: MaterialApp(
debugShowCheckedModeBanner: false, title: 'Smart Serow',
theme: ThemeData( debugShowCheckedModeBanner: false,
colorScheme: ColorScheme.fromSeed( theme: ThemeData(
seedColor: Colors.teal, colorScheme: ColorScheme.fromSeed(
brightness: Brightness.dark, seedColor: AppColors.darkSubdued,
brightness: Brightness.dark,
),
useMaterial3: true,
fontFamily: 'DIN1451',
), ),
useMaterial3: true, home: const AppRoot(),
fontFamily: 'DIN1451',
), ),
home: const AppRoot(),
); );
} }
} }

View File

@@ -1,12 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/config_service.dart';
import '../services/pi_io.dart'; import '../services/pi_io.dart';
import '../theme/app_theme.dart';
import '../widgets/navigator_widget.dart';
import '../widgets/stat_box.dart'; import '../widgets/stat_box.dart';
// test service for triggers
import '../services/test_flipflop_service.dart';
/// Main dashboard - displays Pi vitals and placeholder stats /// Main dashboard - displays Pi vitals and placeholder stats
class DashboardScreen extends StatefulWidget { class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key}); const DashboardScreen({super.key});
@@ -17,6 +20,7 @@ class DashboardScreen extends StatefulWidget {
class _DashboardScreenState extends State<DashboardScreen> { class _DashboardScreenState extends State<DashboardScreen> {
final _random = Random(); final _random = Random();
final _navigatorKey = GlobalKey<NavigatorWidgetState>();
Timer? _timer; Timer? _timer;
double? _piTemp; double? _piTemp;
@@ -40,33 +44,24 @@ class _DashboardScreenState extends State<DashboardScreen> {
_temp = 20 + _random.nextInt(60); _temp = 20 + _random.nextInt(60);
}); });
}); });
// DEBUG: flip-flop theme + navigator every 2s
TestFlipFlopService.instance.start(navigatorKey: _navigatorKey);
} }
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
TestFlipFlopService.instance.stop();
super.dispose(); super.dispose();
} }
/// Build navigator image from filesystem
Widget _buildNavigatorImage() {
final config = ConfigService.instance;
final imagePath = '${config.assetsPath}/navigator/${config.navigator}/default.png';
return Image.file(
File(imagePath),
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
// Graceful fallback - empty box if image missing
return const SizedBox.shrink();
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = AppTheme.of(context);
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: theme.background,
body: Padding( body: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
child: Row( child: Row(
@@ -84,20 +79,20 @@ class _DashboardScreenState extends State<DashboardScreen> {
Text( Text(
'SMART SEROW', 'SMART SEROW',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.teal, color: theme.subdued,
letterSpacing: 2, letterSpacing: 2,
), ),
), ),
Text( Text(
'${_voltage.toStringAsFixed(1)}V', '${_voltage.toStringAsFixed(1)}V',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: _voltage < 12.0 ? Colors.red : Colors.green, color: _voltage < 12.0 ? theme.highlight : theme.foreground,
), ),
), ),
], ],
), ),
const SizedBox(height: 48), const SizedBox(height: 20),
// Main Pi temperature display // Main Pi temperature display
Expanded( Expanded(
@@ -107,17 +102,17 @@ class _DashboardScreenState extends State<DashboardScreen> {
children: [ children: [
Text( Text(
_piTemp != null ? _piTemp!.toStringAsFixed(1) : '', _piTemp != null ? _piTemp!.toStringAsFixed(1) : '',
style: const TextStyle( style: TextStyle(
fontSize: 180, fontSize: 250,
fontWeight: FontWeight.w200, fontWeight: FontWeight.w200,
color: Colors.white, color: theme.foreground,
height: 1, height: 1,
), ),
), ),
Text( Text(
'Pi Temp', 'Pi Temp',
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Colors.grey, color: theme.subdued,
), ),
), ),
], ],
@@ -144,7 +139,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
Expanded( Expanded(
flex: 1, flex: 1,
child: Center( child: Center(
child: _buildNavigatorImage(), child: NavigatorWidget(key: _navigatorKey),
), ),
), ),
], ],

View File

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../services/config_service.dart'; import '../services/config_service.dart';
import '../services/pi_io.dart'; import '../services/pi_io.dart';
import '../theme/app_theme.dart';
/// Overheat warning screen with shutdown countdown /// Overheat warning screen with shutdown countdown
/// ///
@@ -81,30 +82,31 @@ class _OverheatScreenState extends State<OverheatScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = AppTheme.of(context);
final threshold = ConfigService.instance.overheatThreshold; final threshold = ConfigService.instance.overheatThreshold;
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: theme.background,
body: Center( body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Warning icon // Warning icon
const Icon( Icon(
Icons.warning_amber_rounded, Icons.warning_amber_rounded,
size: 100, size: 100,
color: Colors.red, color: theme.highlight,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// OVERHEATING text // OVERHEATING text
const Text( Text(
'OVERHEATING', 'OVERHEATING',
style: TextStyle( style: TextStyle(
fontSize: 48, fontSize: 48,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.red, color: theme.highlight,
letterSpacing: 4, letterSpacing: 4,
), ),
), ),
@@ -114,10 +116,10 @@ class _OverheatScreenState extends State<OverheatScreen> {
// Current temperature // Current temperature
Text( Text(
_currentTemp != null ? '${_currentTemp!.toStringAsFixed(1)}°C' : '', _currentTemp != null ? '${_currentTemp!.toStringAsFixed(1)}°C' : '',
style: const TextStyle( style: TextStyle(
fontSize: 120, fontSize: 120,
fontWeight: FontWeight.w200, fontWeight: FontWeight.w200,
color: Colors.white, color: theme.foreground,
height: 1, height: 1,
), ),
), ),
@@ -127,9 +129,9 @@ class _OverheatScreenState extends State<OverheatScreen> {
// Threshold info // Threshold info
Text( Text(
'Threshold: ${threshold.toStringAsFixed(0)}°C', 'Threshold: ${threshold.toStringAsFixed(0)}°C',
style: const TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
color: Colors.grey, color: theme.subdued,
), ),
), ),
@@ -138,9 +140,9 @@ class _OverheatScreenState extends State<OverheatScreen> {
// Countdown // Countdown
Text( Text(
'Shutdown in $_secondsRemaining s', 'Shutdown in $_secondsRemaining s',
style: const TextStyle( style: TextStyle(
fontSize: 32, fontSize: 32,
color: Colors.orange, color: theme.highlight,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Splash screen - shown during initialization /// Splash screen - shown during initialization
class SplashScreen extends StatelessWidget { class SplashScreen extends StatelessWidget {
final String status; final String status;
@@ -8,8 +10,10 @@ class SplashScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = AppTheme.of(context);
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: theme.background,
body: Center( body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -17,13 +21,13 @@ class SplashScreen extends StatelessWidget {
Icon( Icon(
Icons.terrain, Icons.terrain,
size: 120, size: 120,
color: Theme.of(context).colorScheme.primary, color: theme.subdued,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'Smart Serow', 'Smart Serow',
style: Theme.of(context).textTheme.headlineLarge?.copyWith( style: Theme.of(context).textTheme.headlineLarge?.copyWith(
color: Colors.white, color: theme.foreground,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -31,7 +35,7 @@ class SplashScreen extends StatelessWidget {
Text( Text(
status, status,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey, color: theme.subdued,
), ),
), ),
], ],

View File

@@ -0,0 +1,50 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import '../widgets/navigator_widget.dart';
import 'theme_service.dart';
/// Debug service that flip-flops theme and navigator emotion every 2 seconds.
///
/// Usage:
/// ```dart
/// TestFlipFlopService.instance.start(navigatorKey: _navigatorKey);
/// // Later:
/// TestFlipFlopService.instance.stop();
/// ```
class TestFlipFlopService {
TestFlipFlopService._();
static final instance = TestFlipFlopService._();
Timer? _timer;
bool _running = false;
bool get isRunning => _running;
/// Start the flip-flop cycle.
/// Pass the navigator's GlobalKey to trigger emotion changes.
void start({required GlobalKey<NavigatorWidgetState> navigatorKey}) {
stop();
_running = true;
_timer = Timer.periodic(const Duration(seconds: 2), (_) {
// Toggle theme
ThemeService.instance.toggle();
// Surprise the navigator
if (navigatorKey.currentState?.emotion == 'surprise') {
navigatorKey.currentState?.reset();
} else {
navigatorKey.currentState?.setEmotion('surprise');
}
});
}
/// Stop the flip-flop cycle.
void stop() {
_timer?.cancel();
_timer = null;
_running = false;
}
}

View File

@@ -0,0 +1,40 @@
/// Theme switching service - singleton pattern matching OverheatMonitor
///
/// Manages dark/bright mode state and notifies listeners on change.
/// Default is dark mode. Call setDarkMode() from sensor readings.
class ThemeService {
ThemeService._();
static final instance = ThemeService._();
bool _isDarkMode = true;
final List<void Function()> _listeners = [];
/// Current theme mode
bool get isDarkMode => _isDarkMode;
/// Set theme mode. Notifies listeners if changed.
void setDarkMode(bool dark) {
if (_isDarkMode == dark) return;
_isDarkMode = dark;
_notifyListeners();
}
/// Toggle between dark and bright
void toggle() => setDarkMode(!_isDarkMode);
/// Add a listener for theme changes
void addListener(void Function() listener) {
_listeners.add(listener);
}
/// Remove a listener
void removeListener(void Function() listener) {
_listeners.remove(listener);
}
void _notifyListeners() {
for (final listener in _listeners) {
listener();
}
}
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../services/theme_service.dart';
import 'app_colors.dart';
/// InheritedWidget providing runtime theme colors
///
/// Wraps the app and provides semantic color getters that resolve
/// to dark or bright variants based on ThemeService state.
///
/// Usage: AppTheme.of(context).background
class AppTheme extends InheritedWidget {
final bool isDarkMode;
const AppTheme({
super.key,
required this.isDarkMode,
required super.child,
});
/// Get the nearest AppTheme from context
static AppTheme of(BuildContext context) {
final theme = context.dependOnInheritedWidgetOfExactType<AppTheme>();
assert(theme != null, 'No AppTheme found in context');
return theme!;
}
// Semantic color getters - pick dark or bright based on mode
Color get background => isDarkMode ? AppColors.darkBackground : AppColors.brightBackground;
Color get foreground => isDarkMode ? AppColors.darkForeground : AppColors.brightForeground;
Color get highlight => isDarkMode ? AppColors.darkHighlight : AppColors.brightHighlight;
Color get subdued => isDarkMode ? AppColors.darkSubdued : AppColors.brightSubdued;
@override
bool updateShouldNotify(AppTheme oldWidget) => isDarkMode != oldWidget.isDarkMode;
}
/// Wrapper widget that manages AppTheme state
///
/// Listens to ThemeService and rebuilds when theme changes.
/// Place this at the root of your widget tree.
class AppThemeProvider extends StatefulWidget {
final Widget child;
const AppThemeProvider({super.key, required this.child});
@override
State<AppThemeProvider> createState() => _AppThemeProviderState();
}
class _AppThemeProviderState extends State<AppThemeProvider> {
@override
void initState() {
super.initState();
ThemeService.instance.addListener(_onThemeChanged);
}
@override
void dispose() {
ThemeService.instance.removeListener(_onThemeChanged);
super.dispose();
}
void _onThemeChanged() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return AppTheme(
isDarkMode: ThemeService.instance.isDarkMode,
child: widget.child,
);
}
}

View File

@@ -0,0 +1,59 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../services/config_service.dart';
/// Displays the navigator character with emotion support.
///
/// Use a GlobalKey to control emotions from parent:
/// ```dart
/// final _navigatorKey = GlobalKey<NavigatorWidgetState>();
/// NavigatorWidget(key: _navigatorKey)
/// // Later:
/// _navigatorKey.currentState?.setEmotion('happy');
/// ```
class NavigatorWidget extends StatefulWidget {
const NavigatorWidget({super.key});
@override
State<NavigatorWidget> createState() => NavigatorWidgetState();
}
class NavigatorWidgetState extends State<NavigatorWidget> {
String _emotion = 'default';
/// Change the displayed emotion.
/// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png
void setEmotion(String emotion) {
if (emotion != _emotion) {
setState(() => _emotion = emotion);
}
}
/// Reset to default emotion
void reset() => setEmotion('default');
/// Current emotion
String get emotion => _emotion;
@override
Widget build(BuildContext context) {
final config = ConfigService.instance;
final basePath = '${config.assetsPath}/navigator/${config.navigator}';
return Image.file(
File('$basePath/$_emotion.png'),
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
// Fallback: try default.png if specific emotion missing
if (_emotion != 'default') {
return Image.file(
File('$basePath/default.png'),
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
);
}
return const SizedBox.shrink();
},
);
}
}

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// A labeled stat display box for the dashboard /// A labeled stat display box for the dashboard
class StatBox extends StatelessWidget { class StatBox extends StatelessWidget {
final String label; final String label;
@@ -9,18 +11,22 @@ class StatBox extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = AppTheme.of(context);
return Column( return Column(
children: [ children: [
Text( Text(
value, value,
style: Theme.of(context).textTheme.headlineMedium?.copyWith( style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white, fontSize: 100,
color: theme.foreground,
), ),
), ),
Text( Text(
label, label,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey, fontSize: 80,
color: theme.subdued,
letterSpacing: 1, letterSpacing: 1,
), ),
), ),

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.2"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -71,26 +71,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "11.0.2" version: "10.0.8"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.10" version: "3.0.9"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.2" version: "3.0.1"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@@ -180,18 +180,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.6" version: "0.7.4"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.1.4"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
@@ -201,5 +201,5 @@ packages:
source: hosted source: hosted
version: "14.3.1" version: "14.3.1"
sdks: sdks:
dart: ">=3.8.0-0 <4.0.0" dart: ">=3.7.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.18.0-18.0.pre.54"

View File

@@ -56,11 +56,22 @@ def set_cross_compile_env():
return env_vars return env_vars
def generate_theme() -> bool:
"""Generate theme colors before build."""
import generate_theme as theme_gen
return theme_gen.main()
def build(clean: bool = False) -> bool: def build(clean: bool = False) -> bool:
"""Run the build process. Returns True on success.""" """Run the build process. Returns True on success."""
print("=== Smart Serow Build ===") print("=== Smart Serow Build ===")
print(f"Project: {UI_DIR}") print(f"Project: {UI_DIR}")
# Generate theme colors first
if not generate_theme():
print("ERROR: Theme generation failed")
return False
# Check flutter-elinux # Check flutter-elinux
flutter_path = check_flutter_elinux() flutter_path = check_flutter_elinux()
print(f"Using: {flutter_path}") print(f"Using: {flutter_path}")

128
scripts/generate_theme.py Normal file
View File

@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Theme generator for Smart Serow Flutter UI.
Reads navigator from config, loads corresponding theme JSON,
and generates app_colors.dart with the colour palette.
Fallback chain: {navigator}.json -> default.json -> hardcoded defaults
"""
import json
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve()
PROJECT_ROOT = SCRIPT_DIR.parent
UI_DIR = PROJECT_ROOT / "pi" / "ui"
THEMES_DIR = PROJECT_ROOT / "extra" / "themes"
CONFIG_FILE = UI_DIR / "config.json"
OUTPUT_FILE = UI_DIR / "lib" / "theme" / "app_colors.dart"
# Hardcoded fallback if no theme files exist at all
HARDCODED_THEME = {
"dark": {
"background": "#000000",
"foreground": "#FFFFFF",
"highlight": "#FF5555",
"subdued": "#808080",
},
"bright": {
"background": "#F0F0F0",
"foreground": "#1A1A1A",
"highlight": "#CC0000",
"subdued": "#606060",
},
}
def hex_to_flutter(hex_color: str) -> str:
"""Convert #RRGGBB to 0xFFRRGGBB format."""
hex_clean = hex_color.lstrip("#").upper()
return f"0xFF{hex_clean}"
def load_config() -> dict:
"""Load UI config, return empty dict if missing."""
if CONFIG_FILE.exists():
with open(CONFIG_FILE) as f:
return json.load(f)
return {}
def load_theme(navigator: str) -> dict:
"""Load theme for navigator with fallback chain."""
# Try navigator-specific theme
nav_theme = THEMES_DIR / f"{navigator}.json"
if nav_theme.exists():
print(f" Using theme: {nav_theme.name}")
with open(nav_theme) as f:
return json.load(f)
# Try default theme
default_theme = THEMES_DIR / "default.json"
if default_theme.exists():
print(f" Fallback to: default.json")
with open(default_theme) as f:
return json.load(f)
# Last resort: hardcoded
print(f" Using hardcoded defaults")
return HARDCODED_THEME
def generate_dart(theme: dict) -> str:
"""Generate Dart source from theme dict."""
dark = theme["dark"]
bright = theme["bright"]
return f'''import 'package:flutter/material.dart';
/// Auto-generated from theme config. Do not edit manually.
/// Run scripts/generate_theme.py to regenerate.
class AppColors {{
AppColors._();
// Dark theme (low ambient light)
static const darkBackground = Color({hex_to_flutter(dark["background"])});
static const darkForeground = Color({hex_to_flutter(dark["foreground"])});
static const darkHighlight = Color({hex_to_flutter(dark["highlight"])});
static const darkSubdued = Color({hex_to_flutter(dark["subdued"])});
// Bright theme (high ambient light)
static const brightBackground = Color({hex_to_flutter(bright["background"])});
static const brightForeground = Color({hex_to_flutter(bright["foreground"])});
static const brightHighlight = Color({hex_to_flutter(bright["highlight"])});
static const brightSubdued = Color({hex_to_flutter(bright["subdued"])});
}}
'''
def main():
print("=== Theme Generation ===")
# Get navigator from config
config = load_config()
navigator = config.get("navigator", "default")
print(f" Navigator: {navigator}")
# Load theme
theme = load_theme(navigator)
# Generate Dart
dart_source = generate_dart(theme)
# Ensure output directory exists
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
# Write output
with open(OUTPUT_FILE, "w") as f:
f.write(dart_source)
print(f" Generated: {OUTPUT_FILE.relative_to(PROJECT_ROOT)}")
return True
if __name__ == "__main__":
success = main()
sys.exit(0 if success else 1)

View File

@@ -27,6 +27,12 @@ echo "Creating app directory: $APP_DIR"
sudo mkdir -p "$APP_DIR/bundle" sudo mkdir -p "$APP_DIR/bundle"
sudo chown -R "$PI_USER:$PI_USER" "$APP_DIR" sudo chown -R "$PI_USER:$PI_USER" "$APP_DIR"
# Create assets directory in user home (for navigator images, etc.)
ASSETS_DIR="/home/$PI_USER/smartserow-ui/assets"
echo "Creating assets directory: $ASSETS_DIR"
mkdir -p "$ASSETS_DIR/navigator"
echo " (deploy.py will sync extra/images here)"
# Install runtime dependencies for flutter-elinux # Install runtime dependencies for flutter-elinux
echo "Installing runtime dependencies..." echo "Installing runtime dependencies..."
sudo apt-get update sudo apt-get update