From c7edc30b79c89cd659c7759bac9fd383eb488f21 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Mon, 26 Jan 2026 00:20:52 +0900 Subject: [PATCH] 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 --- README.md | 11 ++ extra/themes/default.json | 14 ++ extra/themes/rei.json | 14 ++ pi/ui/.gitignore | 3 + pi/ui/config.json | 2 +- pi/ui/lib/main.dart | 24 ++-- pi/ui/lib/screens/dashboard_screen.dart | 47 +++---- pi/ui/lib/screens/overheat_screen.dart | 24 ++-- pi/ui/lib/screens/splash_screen.dart | 12 +- pi/ui/lib/services/test_flipflop_service.dart | 50 +++++++ pi/ui/lib/services/theme_service.dart | 40 ++++++ pi/ui/lib/theme/app_theme.dart | 75 ++++++++++ pi/ui/lib/widgets/navigator_widget.dart | 59 ++++++++ pi/ui/lib/widgets/stat_box.dart | 10 +- pi/ui/pubspec.lock | 26 ++-- scripts/build.py | 11 ++ scripts/generate_theme.py | 128 ++++++++++++++++++ scripts/pi_setup.sh | 6 + 18 files changed, 489 insertions(+), 67 deletions(-) create mode 100644 extra/themes/default.json create mode 100644 extra/themes/rei.json create mode 100644 pi/ui/lib/services/test_flipflop_service.dart create mode 100644 pi/ui/lib/services/theme_service.dart create mode 100644 pi/ui/lib/theme/app_theme.dart create mode 100644 pi/ui/lib/widgets/navigator_widget.dart create mode 100644 scripts/generate_theme.py diff --git a/README.md b/README.md index 9926076..92fea10 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,17 @@ smart-serow/ └── 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) diff --git a/extra/themes/default.json b/extra/themes/default.json new file mode 100644 index 0000000..5b52a37 --- /dev/null +++ b/extra/themes/default.json @@ -0,0 +1,14 @@ +{ + "dark": { + "background": "#000000", + "foreground": "#FFFFFF", + "highlight": "#FF5555", + "subdued": "#808080" + }, + "bright": { + "background": "#F0F0F0", + "foreground": "#1A1A1A", + "highlight": "#CC0000", + "subdued": "#606060" + } +} diff --git a/extra/themes/rei.json b/extra/themes/rei.json new file mode 100644 index 0000000..76b988b --- /dev/null +++ b/extra/themes/rei.json @@ -0,0 +1,14 @@ +{ + "dark": { + "background": "#101010", + "foreground": "#EAEAEA", + "highlight": "#FA1504", + "subdued": "#E47841" + }, + "bright": { + "background": "#E47841", + "foreground": "#202020", + "highlight": "#F0F0F0", + "subdued": "#BC4600" + } +} diff --git a/pi/ui/.gitignore b/pi/ui/.gitignore index 79c113f..d560add 100644 --- a/pi/ui/.gitignore +++ b/pi/ui/.gitignore @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Generated theme +lib/theme/app_colors.dart diff --git a/pi/ui/config.json b/pi/ui/config.json index 6e045a8..9227b98 100644 --- a/pi/ui/config.json +++ b/pi/ui/config.json @@ -1,5 +1,5 @@ { - "assets_path": "/home/pi/smartserow-ui/assets", + "assets_path": "/home/mikkeli/smartserow-ui/assets", "navigator": "rei", "overheat": { "threshold_celsius": 75.0, diff --git a/pi/ui/lib/main.dart b/pi/ui/lib/main.dart index 272c3ca..6eee4a8 100644 --- a/pi/ui/lib/main.dart +++ b/pi/ui/lib/main.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'app_root.dart'; +import 'theme/app_colors.dart'; +import 'theme/app_theme.dart'; void main() { runApp(const SmartSerowApp()); @@ -11,18 +13,20 @@ class SmartSerowApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Smart Serow', - debugShowCheckedModeBanner: false, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.teal, - brightness: Brightness.dark, + return AppThemeProvider( + child: MaterialApp( + title: 'Smart Serow', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: AppColors.darkSubdued, + brightness: Brightness.dark, + ), + useMaterial3: true, + fontFamily: 'DIN1451', ), - useMaterial3: true, - fontFamily: 'DIN1451', + home: const AppRoot(), ), - home: const AppRoot(), ); } } diff --git a/pi/ui/lib/screens/dashboard_screen.dart b/pi/ui/lib/screens/dashboard_screen.dart index 8f1c334..57005a7 100644 --- a/pi/ui/lib/screens/dashboard_screen.dart +++ b/pi/ui/lib/screens/dashboard_screen.dart @@ -1,12 +1,15 @@ import 'dart:async'; -import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; -import '../services/config_service.dart'; import '../services/pi_io.dart'; +import '../theme/app_theme.dart'; +import '../widgets/navigator_widget.dart'; import '../widgets/stat_box.dart'; +// test service for triggers +import '../services/test_flipflop_service.dart'; + /// Main dashboard - displays Pi vitals and placeholder stats class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -17,6 +20,7 @@ class DashboardScreen extends StatefulWidget { class _DashboardScreenState extends State { final _random = Random(); + final _navigatorKey = GlobalKey(); Timer? _timer; double? _piTemp; @@ -40,33 +44,24 @@ class _DashboardScreenState extends State { _temp = 20 + _random.nextInt(60); }); }); + + // DEBUG: flip-flop theme + navigator every 2s + TestFlipFlopService.instance.start(navigatorKey: _navigatorKey); } @override void dispose() { _timer?.cancel(); + TestFlipFlopService.instance.stop(); 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 Widget build(BuildContext context) { + final theme = AppTheme.of(context); + return Scaffold( - backgroundColor: Colors.black, + backgroundColor: theme.background, body: Padding( padding: const EdgeInsets.all(32), child: Row( @@ -84,20 +79,20 @@ class _DashboardScreenState extends State { Text( 'SMART SEROW', style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Colors.teal, + color: theme.subdued, letterSpacing: 2, ), ), Text( '${_voltage.toStringAsFixed(1)}V', 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 Expanded( @@ -107,17 +102,17 @@ class _DashboardScreenState extends State { children: [ Text( _piTemp != null ? _piTemp!.toStringAsFixed(1) : '—', - style: const TextStyle( - fontSize: 180, + style: TextStyle( + fontSize: 250, fontWeight: FontWeight.w200, - color: Colors.white, + color: theme.foreground, height: 1, ), ), Text( 'Pi Temp', style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: Colors.grey, + color: theme.subdued, ), ), ], @@ -144,7 +139,7 @@ class _DashboardScreenState extends State { Expanded( flex: 1, child: Center( - child: _buildNavigatorImage(), + child: NavigatorWidget(key: _navigatorKey), ), ), ], diff --git a/pi/ui/lib/screens/overheat_screen.dart b/pi/ui/lib/screens/overheat_screen.dart index 443657e..af3cc2d 100644 --- a/pi/ui/lib/screens/overheat_screen.dart +++ b/pi/ui/lib/screens/overheat_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../services/config_service.dart'; import '../services/pi_io.dart'; +import '../theme/app_theme.dart'; /// Overheat warning screen with shutdown countdown /// @@ -81,30 +82,31 @@ class _OverheatScreenState extends State { @override Widget build(BuildContext context) { + final theme = AppTheme.of(context); final threshold = ConfigService.instance.overheatThreshold; return Scaffold( - backgroundColor: Colors.black, + backgroundColor: theme.background, body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Warning icon - const Icon( + Icon( Icons.warning_amber_rounded, size: 100, - color: Colors.red, + color: theme.highlight, ), const SizedBox(height: 16), // OVERHEATING text - const Text( + Text( 'OVERHEATING', style: TextStyle( fontSize: 48, fontWeight: FontWeight.bold, - color: Colors.red, + color: theme.highlight, letterSpacing: 4, ), ), @@ -114,10 +116,10 @@ class _OverheatScreenState extends State { // Current temperature Text( _currentTemp != null ? '${_currentTemp!.toStringAsFixed(1)}°C' : '—', - style: const TextStyle( + style: TextStyle( fontSize: 120, fontWeight: FontWeight.w200, - color: Colors.white, + color: theme.foreground, height: 1, ), ), @@ -127,9 +129,9 @@ class _OverheatScreenState extends State { // Threshold info Text( 'Threshold: ${threshold.toStringAsFixed(0)}°C', - style: const TextStyle( + style: TextStyle( fontSize: 24, - color: Colors.grey, + color: theme.subdued, ), ), @@ -138,9 +140,9 @@ class _OverheatScreenState extends State { // Countdown Text( 'Shutdown in $_secondsRemaining s', - style: const TextStyle( + style: TextStyle( fontSize: 32, - color: Colors.orange, + color: theme.highlight, fontWeight: FontWeight.w500, ), ), diff --git a/pi/ui/lib/screens/splash_screen.dart b/pi/ui/lib/screens/splash_screen.dart index d09dd78..9be01fe 100644 --- a/pi/ui/lib/screens/splash_screen.dart +++ b/pi/ui/lib/screens/splash_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + /// Splash screen - shown during initialization class SplashScreen extends StatelessWidget { final String status; @@ -8,8 +10,10 @@ class SplashScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppTheme.of(context); + return Scaffold( - backgroundColor: Colors.black, + backgroundColor: theme.background, body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -17,13 +21,13 @@ class SplashScreen extends StatelessWidget { Icon( Icons.terrain, size: 120, - color: Theme.of(context).colorScheme.primary, + color: theme.subdued, ), const SizedBox(height: 24), Text( 'Smart Serow', style: Theme.of(context).textTheme.headlineLarge?.copyWith( - color: Colors.white, + color: theme.foreground, fontWeight: FontWeight.bold, ), ), @@ -31,7 +35,7 @@ class SplashScreen extends StatelessWidget { Text( status, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Colors.grey, + color: theme.subdued, ), ), ], diff --git a/pi/ui/lib/services/test_flipflop_service.dart b/pi/ui/lib/services/test_flipflop_service.dart new file mode 100644 index 0000000..0a1f607 --- /dev/null +++ b/pi/ui/lib/services/test_flipflop_service.dart @@ -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 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; + } +} diff --git a/pi/ui/lib/services/theme_service.dart b/pi/ui/lib/services/theme_service.dart new file mode 100644 index 0000000..c633d09 --- /dev/null +++ b/pi/ui/lib/services/theme_service.dart @@ -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 _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(); + } + } +} diff --git a/pi/ui/lib/theme/app_theme.dart b/pi/ui/lib/theme/app_theme.dart new file mode 100644 index 0000000..921969e --- /dev/null +++ b/pi/ui/lib/theme/app_theme.dart @@ -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(); + 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 createState() => _AppThemeProviderState(); +} + +class _AppThemeProviderState extends State { + @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, + ); + } +} diff --git a/pi/ui/lib/widgets/navigator_widget.dart b/pi/ui/lib/widgets/navigator_widget.dart new file mode 100644 index 0000000..263ee75 --- /dev/null +++ b/pi/ui/lib/widgets/navigator_widget.dart @@ -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(); +/// NavigatorWidget(key: _navigatorKey) +/// // Later: +/// _navigatorKey.currentState?.setEmotion('happy'); +/// ``` +class NavigatorWidget extends StatefulWidget { + const NavigatorWidget({super.key}); + + @override + State createState() => NavigatorWidgetState(); +} + +class NavigatorWidgetState extends State { + 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(); + }, + ); + } +} diff --git a/pi/ui/lib/widgets/stat_box.dart b/pi/ui/lib/widgets/stat_box.dart index 2ef0644..a9b520e 100644 --- a/pi/ui/lib/widgets/stat_box.dart +++ b/pi/ui/lib/widgets/stat_box.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + /// A labeled stat display box for the dashboard class StatBox extends StatelessWidget { final String label; @@ -9,18 +11,22 @@ class StatBox extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppTheme.of(context); + return Column( children: [ Text( value, style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, + fontSize: 100, + color: theme.foreground, ), ), Text( label, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey, + fontSize: 80, + color: theme.subdued, letterSpacing: 1, ), ), diff --git a/pi/ui/pubspec.lock b/pi/ui/pubspec.lock index e4bb9bc..5928308 100644 --- a/pi/ui/pubspec.lock +++ b/pi/ui/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.3.2" flutter: dependency: "direct main" description: flutter @@ -71,26 +71,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.10" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.1" lints: dependency: transitive description: @@ -180,18 +180,18 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.4" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" vm_service: dependency: transitive description: @@ -201,5 +201,5 @@ packages: source: hosted version: "14.3.1" 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" diff --git a/scripts/build.py b/scripts/build.py index dcacfe6..d6aa98b 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -56,11 +56,22 @@ def set_cross_compile_env(): 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: """Run the build process. Returns True on success.""" print("=== Smart Serow Build ===") print(f"Project: {UI_DIR}") + # Generate theme colors first + if not generate_theme(): + print("ERROR: Theme generation failed") + return False + # Check flutter-elinux flutter_path = check_flutter_elinux() print(f"Using: {flutter_path}") diff --git a/scripts/generate_theme.py b/scripts/generate_theme.py new file mode 100644 index 0000000..5e81a99 --- /dev/null +++ b/scripts/generate_theme.py @@ -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) diff --git a/scripts/pi_setup.sh b/scripts/pi_setup.sh index c071cd0..5e687f5 100644 --- a/scripts/pi_setup.sh +++ b/scripts/pi_setup.sh @@ -27,6 +27,12 @@ echo "Creating app directory: $APP_DIR" sudo mkdir -p "$APP_DIR/bundle" 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 echo "Installing runtime dependencies..." sudo apt-get update