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

@@ -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(),
);
}
}

View File

@@ -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),
),
),
],

View File

@@ -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,
),
),

View File

@@ -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,
),
),
],

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 '../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,
),
),