diff --git a/lib/constants.dart b/lib/constants.dart index 323a4c469..2134bb380 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -144,3 +144,9 @@ String maxValue = 'Max: '; String gyroscopeTitle = "Gyroscope"; String gyroscopeAxisLabel = 'rad/s'; String noData = 'No data available'; +String luxMeterTitle = 'Lux Meter'; +String builtIn = 'Built-In'; +String lx = 'Lx'; +String maxScaleError = 'Max Scale'; +String lightSensorError = 'Light sensor error:'; +String lightSensorInitialError = 'Failed to initialize light sensor:'; diff --git a/lib/main.dart b/lib/main.dart index a69876861..c5b0674d7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:pslab/view/connect_device_screen.dart'; import 'package:pslab/view/faq_screen.dart'; import 'package:pslab/view/gyroscope_screen.dart'; import 'package:pslab/view/instruments_screen.dart'; +import 'package:pslab/view/luxmeter_screen.dart'; import 'package:pslab/view/oscilloscope_screen.dart'; import 'package:pslab/view/settings_screen.dart'; import 'package:pslab/view/about_us_screen.dart'; @@ -32,7 +33,6 @@ void main() { class MyApp extends StatelessWidget { const MyApp({super.key}); - @override Widget build(BuildContext context) { _preCacheImages(context); @@ -52,6 +52,7 @@ class MyApp extends StatelessWidget { '/softwareLicenses': (context) => const SoftwareLicensesScreen(), '/accelerometer': (context) => const AccelerometerScreen(), '/gyroscope': (context) => const GyroscopeScreen(), + '/luxmeter': (context) => const LuxMeterScreen(), }, ); } diff --git a/lib/providers/luxmeter_state_provider.dart b/lib/providers/luxmeter_state_provider.dart new file mode 100644 index 000000000..e2f182ab3 --- /dev/null +++ b/lib/providers/luxmeter_state_provider.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:pslab/others/logger_service.dart'; +import 'package:light/light.dart'; +import 'package:flutter/foundation.dart'; +import 'package:pslab/constants.dart'; + +class LuxMeterStateProvider extends ChangeNotifier { + double _currentLux = 0.0; + StreamSubscription? _lightSubscription; + Timer? _timeTimer; + final List _luxData = []; + final List _timeData = []; + final List luxChartData = []; + Light? _light; + double _startTime = 0; + double _currentTime = 0; + final int _maxLength = 50; + double _luxMin = 0; + double _luxMax = 0; + double _luxSum = 0; + int _dataCount = 0; + void initializeSensors() { + try { + _light = Light(); + _startTime = DateTime.now().millisecondsSinceEpoch / 1000.0; + _timeTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + _currentTime = + (DateTime.now().millisecondsSinceEpoch / 1000.0) - _startTime; + _updateData(); + notifyListeners(); + }); + _lightSubscription = _light!.lightSensorStream.listen( + (int luxValue) { + _currentLux = luxValue.toDouble(); + notifyListeners(); + }, + onError: (error) { + logger.e( + "$lightSensorError $error", + ); + }, + cancelOnError: true, + ); + } catch (e) { + logger.e("$lightSensorInitialError $e"); + } + } + + void disposeSensors() { + _lightSubscription?.cancel(); + _timeTimer?.cancel(); + } + + @override + void dispose() { + disposeSensors(); + super.dispose(); + } + + void _updateData() { + final lux = _currentLux; + final time = _currentTime; + _luxData.add(lux); + _timeData.add(time); + _luxSum += lux; + _dataCount++; + if (_luxData.length > _maxLength) { + final removedValue = _luxData.removeAt(0); + _timeData.removeAt(0); + _luxSum -= removedValue; + _dataCount--; + } + if (_luxData.isNotEmpty) { + _luxMin = _luxData.reduce(min); + _luxMax = _luxData.reduce(max); + } + luxChartData.clear(); + for (int i = 0; i < _luxData.length; i++) { + luxChartData.add(FlSpot(_timeData[i], _luxData[i])); + } + notifyListeners(); + } + + double getCurrentLux() => _currentLux; + double getMinLux() => _luxMin; + double getMaxLux() => _luxMax; + double getAverageLux() => _dataCount > 0 ? _luxSum / _dataCount : 0.0; + List getLuxChartData() => luxChartData; + int getDataLength() => luxChartData.length; + double getCurrentTime() => _currentTime; + double getMaxTime() => _timeData.isNotEmpty ? _timeData.last : 0; + double getMinTime() => _timeData.isNotEmpty ? _timeData.first : 0; + double getTimeInterval() { + if (_currentTime <= 10) return 2; + if (_currentTime <= 30) return 5; + return 10; + } +} diff --git a/lib/view/instruments_screen.dart b/lib/view/instruments_screen.dart index 4ca570920..70d40342c 100644 --- a/lib/view/instruments_screen.dart +++ b/lib/view/instruments_screen.dart @@ -7,7 +7,6 @@ import 'package:pslab/view/widgets/main_scaffold_widget.dart'; class InstrumentsScreen extends StatefulWidget { const InstrumentsScreen({super.key}); - @override State createState() => _InstrumentsScreenState(); } @@ -38,6 +37,18 @@ class _InstrumentsScreenState extends State { ); } break; + case 6: + if (Navigator.canPop(context) && + ModalRoute.of(context)?.settings.name == '/luxmeter') { + Navigator.popUntil(context, ModalRoute.withName('/luxmeter')); + } else { + Navigator.pushNamedAndRemoveUntil( + context, + '/luxmeter', + (route) => route.isFirst, + ); + } + break; case 10: if (Navigator.canPop(context) && ModalRoute.of(context)?.settings.name == '/gyroscope') { diff --git a/lib/view/luxmeter_screen.dart b/lib/view/luxmeter_screen.dart new file mode 100644 index 000000000..16288eed1 --- /dev/null +++ b/lib/view/luxmeter_screen.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:pslab/constants.dart'; +import 'package:pslab/providers/luxmeter_state_provider.dart'; +import 'package:pslab/view/widgets/common_scaffold_widget.dart'; +import 'package:pslab/view/widgets/luxmeter_card.dart'; +import 'package:fl_chart/fl_chart.dart'; + +class LuxMeterScreen extends StatefulWidget { + const LuxMeterScreen({super.key}); + @override + State createState() => _LuxMeterScreenState(); +} + +class _LuxMeterScreenState extends State { + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => LuxMeterStateProvider()..initializeSensors(), + ), + ], + child: CommonScaffold( + title: luxMeterTitle, + body: SafeArea( + child: Column( + children: [ + const Expanded( + flex: 45, + child: LuxMeterCard(), + ), + Expanded( + flex: 55, + child: _buildChartSection(), + ), + ], + ), + ), + ), + ); + } + + Widget _buildChartSection() { + return Consumer( + builder: (context, provider, child) { + final screenWidth = MediaQuery.of(context).size.width; + final cardMargin = screenWidth < 400 ? 8.0 : 16.0; + List spots = provider.getLuxChartData(); + double maxLux = provider.getMaxLux(); + double maxTime = provider.getMaxTime(); + double minTime = provider.getMinTime(); + double timeInterval = provider.getTimeInterval(); + + return Container( + margin: EdgeInsets.fromLTRB(cardMargin, 0, cardMargin, cardMargin), + padding: EdgeInsets.all(cardMargin), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(8), + ), + child: _buildChart( + screenWidth, maxLux, maxTime, minTime, timeInterval, spots), + ); + }, + ); + } + + Widget sideTitleWidgets(double value, TitleMeta meta) { + final screenWidth = MediaQuery.of(context).size.width; + final fontSize = screenWidth < 400 + ? 7.0 + : screenWidth < 600 + ? 8.0 + : 9.0; + final style = TextStyle( + color: Colors.white, + fontSize: fontSize, + ); + String timeText; + if (value < 60) { + timeText = '${value.toInt()}s'; + } else if (value < 3600) { + int minutes = (value / 60).floor(); + int seconds = (value % 60).toInt(); + timeText = '${minutes}m${seconds}s'; + } else { + int hours = (value / 3600).floor(); + int minutes = ((value % 3600) / 60).floor(); + timeText = '${hours}h${minutes}m'; + } + return SideTitleWidget( + meta: meta, + child: Text( + maxLines: 1, + timeText, + style: style, + ), + ); + } + + Widget _buildChart(double screenWidth, double maxLux, double maxTime, + double minTime, double timeInterval, List spots) { + final chartFontSize = screenWidth < 400 + ? 8.0 + : screenWidth < 600 + ? 9.0 + : 10.0; + final axisNameFontSize = screenWidth < 400 ? 9.0 : 10.0; + final reservedSizeBottom = screenWidth < 400 ? 25.0 : 30.0; + final reservedSizeLeft = screenWidth < 400 ? 20.0 : 25.0; + return LineChart( + LineChartData( + backgroundColor: Colors.black, + titlesData: FlTitlesData( + show: true, + topTitles: AxisTitles( + axisNameWidget: Padding( + padding: EdgeInsets.only(left: screenWidth < 400 ? 15 : 25), + child: Text( + timeAxisLabel, + style: TextStyle( + fontSize: axisNameFontSize, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + axisNameSize: screenWidth < 400 ? 18 : 20, + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: reservedSizeBottom, + getTitlesWidget: sideTitleWidgets, + interval: timeInterval, + ), + ), + leftTitles: AxisTitles( + axisNameWidget: Text( + lx, + style: TextStyle( + fontSize: axisNameFontSize, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + sideTitles: SideTitles( + reservedSize: reservedSizeLeft, + showTitles: true, + getTitlesWidget: (value, meta) { + return SideTitleWidget( + meta: meta, + child: Text( + value.toInt().toString(), + style: TextStyle( + color: Colors.white, + fontSize: chartFontSize, + ), + ), + ); + }, + interval: maxLux > 0 ? (maxLux / 5).ceilToDouble() : 10, + ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: FlGridData( + show: true, + drawHorizontalLine: true, + drawVerticalLine: true, + horizontalInterval: maxLux > 0 ? (maxLux / 5).ceilToDouble() : 10, + verticalInterval: timeInterval, + ), + borderData: FlBorderData( + show: true, + border: const Border( + bottom: BorderSide(color: Colors.white38), + left: BorderSide(color: Colors.white38), + top: BorderSide(color: Colors.white38), + right: BorderSide(color: Colors.white38), + ), + ), + minY: 0, + maxY: maxLux > 0 ? (maxLux * 1.1) : 100, + maxX: maxTime > 0 ? maxTime : 10, + minX: minTime, + clipData: const FlClipData.all(), + lineBarsData: [ + LineChartBarData( + spots: spots, + isCurved: true, + color: Colors.cyan, + barWidth: screenWidth < 400 ? 1.5 : 2.0, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + ], + ), + ); + } +} diff --git a/lib/view/widgets/gauge_widget.dart b/lib/view/widgets/gauge_widget.dart new file mode 100644 index 000000000..6af5db498 --- /dev/null +++ b/lib/view/widgets/gauge_widget.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:gauge_indicator/gauge_indicator.dart'; +import 'dart:math'; +import 'package:pslab/constants.dart'; + +class GaugeWidget extends StatelessWidget { + final double gaugeSize; + final double currentValue; + final double currentValueFontSize; + final double minValue; + final double maxValue; + final String unit; + const GaugeWidget( + {super.key, + required this.gaugeSize, + required this.currentValue, + required this.maxValue, + required this.minValue, + required this.unit, + required this.currentValueFontSize}); + @override + Widget build(BuildContext context) { + double range = maxValue - minValue; + double normalizedValue = (currentValue - minValue) / range; + double gaugeValue = normalizedValue * 100; + gaugeValue = gaugeValue.clamp(0.0, 100.0); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Stack( + alignment: Alignment.center, + children: [ + ClipOval( + child: Container( + width: gaugeSize, + height: gaugeSize, + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.5, + maxHeight: MediaQuery.of(context).size.width * 0.5, + ), + decoration: BoxDecoration( + color: Colors.cyanAccent[400], + shape: BoxShape.circle, + ), + child: Padding( + padding: EdgeInsets.fromLTRB(gaugeSize * 0.06, 0, + gaugeSize * 0.06, gaugeSize * 0.13), + child: AnimatedRadialGauge( + duration: const Duration(milliseconds: 500), + curve: Curves.elasticOut, + radius: gaugeSize * 0.45, + value: gaugeValue, + axis: GaugeAxis( + min: 0, + max: 100, + degrees: 270, + style: GaugeAxisStyle( + thickness: gaugeSize * 0.05, + background: Colors.cyanAccent[100], + segmentSpacing: 2, + ), + progressBar: const GaugeProgressBar.basic( + color: Colors.white, + ), + pointer: GaugePointer.needle( + width: gaugeSize * 0.09, + height: gaugeSize * 0.35, + borderRadius: 12, + color: Colors.white, + ), + ), + ), + ), + ), + ), + Container( + width: gaugeSize * 0.6, + height: gaugeSize * 0.6, + decoration: const BoxDecoration( + color: Colors.transparent, + shape: BoxShape.circle, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + '${currentValue.toStringAsFixed(1)} $unit', + style: TextStyle( + fontSize: currentValueFontSize * 1.0, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + if (currentValue > maxValue) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + maxScaleError, + style: TextStyle( + fontSize: currentValueFontSize * 0.4, + color: Colors.red, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ...List.generate(8, (index) { + final angle = (index * 45.0 - 135) * (3.14159 / 180); + final tickRadius = gaugeSize * 0.35; + final tickLength = gaugeSize * 0.04; + return Positioned( + left: gaugeSize / 2 + (tickRadius * cos(angle)) - 1, + top: gaugeSize / 2 + + (tickRadius * sin(angle)) - + tickLength / 2, + child: Transform.rotate( + angle: angle + (3.14159 / 2), + child: Container( + width: 3, + height: tickLength, + decoration: BoxDecoration( + color: Colors.white.withAlpha(250), + borderRadius: BorderRadius.circular(1), + ), + ), + ), + ); + }), + ], + ), + ), + ], + ); + } +} diff --git a/lib/view/widgets/instruments_stats.dart b/lib/view/widgets/instruments_stats.dart new file mode 100644 index 000000000..4e2745b18 --- /dev/null +++ b/lib/view/widgets/instruments_stats.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:pslab/constants.dart'; + +class Instrumentstats extends StatelessWidget { + final String unit; + final double titleFontSize; + final double statFontSize; + final double minValue; + final double maxValue; + final double avgValue; + + const Instrumentstats( + {super.key, + required this.unit, + required this.titleFontSize, + required this.avgValue, + required this.maxValue, + required this.minValue, + required this.statFontSize}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Center( + child: Text( + builtIn, + style: TextStyle( + color: Colors.black, + fontSize: titleFontSize, + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + StatItem( + label: 'Max (Lx)', value: maxValue, fontSize: statFontSize), + StatItem( + label: 'Min (Lx)', value: minValue, fontSize: statFontSize), + StatItem( + label: 'Avg (Lx)', value: avgValue, fontSize: statFontSize), + ], + ), + ), + ), + ], + ); + } +} + +class StatItem extends StatelessWidget { + final String label; + final double value; + final double fontSize; + + const StatItem( + {super.key, + required this.label, + required this.fontSize, + required this.value}); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final valueFontSize = screenWidth < 400 ? 14.0 : 16.0; + final padding = screenWidth < 400 ? 15.0 : 20.0; + return Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Text( + label, + style: TextStyle( + color: Colors.black, + fontSize: fontSize, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 4), + Center( + child: Container( + padding: EdgeInsets.symmetric(horizontal: padding, vertical: 3), + decoration: BoxDecoration( + border: Border.all(color: Colors.pink.shade200), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + value.toStringAsFixed(2), + style: TextStyle( + color: Colors.black, + fontSize: valueFontSize, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/widgets/luxmeter_card.dart b/lib/view/widgets/luxmeter_card.dart new file mode 100644 index 000000000..5fd9f1f74 --- /dev/null +++ b/lib/view/widgets/luxmeter_card.dart @@ -0,0 +1,77 @@ +import 'package:pslab/view/widgets/gauge_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:pslab/providers/luxmeter_state_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:pslab/view/widgets/instruments_stats.dart'; + +class LuxMeterCard extends StatefulWidget { + const LuxMeterCard({super.key}); + @override + State createState() => _LuxMeterCardState(); +} + +class _LuxMeterCardState extends State { + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final isLargeScreen = screenWidth > 900; + LuxMeterStateProvider provider = + Provider.of(context); + double currentLux = provider.getCurrentLux(); + double minLux = provider.getMinLux(); + double maxLux = provider.getMaxLux(); + double avgLux = provider.getAverageLux(); + final cardMargin = screenWidth < 400 ? 8.0 : 16.0; + final cardPadding = screenWidth < 400 ? 12.0 : 20.0; + final gaugeSize = isLargeScreen ? 240.0 : screenWidth * 0.45; + final titleFontSize = isLargeScreen ? 25.0 : 20.0; + final statFontSize = isLargeScreen ? 20.0 : 15.0; + final luxValueFontSize = isLargeScreen ? 20.0 : 16.0; + + return Card( + margin: EdgeInsets.all(cardMargin), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 1, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Container( + padding: EdgeInsets.all(cardPadding), + child: LayoutBuilder( + builder: (context, constraints) { + return Row( + children: [ + Expanded( + flex: screenWidth < 500 ? 40 : 35, + child: Instrumentstats( + titleFontSize: titleFontSize, + statFontSize: statFontSize, + maxValue: maxLux, + minValue: minLux, + avgValue: avgLux, + unit: 'Lx', + ), + ), + Expanded( + flex: screenWidth < 500 ? 60 : 65, + child: GaugeWidget( + gaugeSize: gaugeSize, + currentValue: currentLux, + minValue: 0, + maxValue: 10000, + unit: 'Lx', + currentValueFontSize: luxValueFontSize), + ), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index d3490730a..31f527535 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,7 +55,8 @@ dependencies: font_awesome_flutter: ^10.8.0 package_info_plus: ^8.3.0 sensors_plus: ^6.1.1 - + gauge_indicator: ^0.4.3 + light: ^4.1.0 dev_dependencies: