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:
11
README.md
11
README.md
@@ -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
14
extra/themes/default.json
Normal 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
14
extra/themes/rei.json
Normal 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
3
pi/ui/.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
50
pi/ui/lib/services/test_flipflop_service.dart
Normal file
50
pi/ui/lib/services/test_flipflop_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
pi/ui/lib/services/theme_service.dart
Normal file
40
pi/ui/lib/services/theme_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
pi/ui/lib/theme/app_theme.dart
Normal file
75
pi/ui/lib/theme/app_theme.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
pi/ui/lib/widgets/navigator_widget.dart
Normal file
59
pi/ui/lib/widgets/navigator_widget.dart
Normal 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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
128
scripts/generate_theme.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user