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:
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DashboardScreen> {
|
||||
final _random = Random();
|
||||
final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||
Timer? _timer;
|
||||
|
||||
double? _piTemp;
|
||||
@@ -40,33 +44,24 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
_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<DashboardScreen> {
|
||||
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<DashboardScreen> {
|
||||
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<DashboardScreen> {
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Center(
|
||||
child: _buildNavigatorImage(),
|
||||
child: NavigatorWidget(key: _navigatorKey),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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<OverheatScreen> {
|
||||
|
||||
@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<OverheatScreen> {
|
||||
// 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<OverheatScreen> {
|
||||
// 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<OverheatScreen> {
|
||||
// Countdown
|
||||
Text(
|
||||
'Shutdown in $_secondsRemaining s',
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
color: Colors.orange,
|
||||
color: theme.highlight,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
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 '../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,
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user