diff --git a/lib/constants.dart b/lib/constants.dart index 0d6e120f2..4ec724a9e 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -256,6 +256,13 @@ String lx = 'Lx'; String maxScaleError = 'Max Scale'; String lightSensorError = 'Light sensor error:'; String lightSensorInitialError = 'Failed to initialize light sensor:'; +String barometerTitle = 'Barometer'; +String atm = 'atm'; +String barometerSensorInitialError = 'Failed to initialize barometer sensor:'; +String barometerSensorError = 'Barometer sensor error occurred'; +String barometerNotAvailable = 'Barometer sensor not available on this device'; +String meterUnit = 'm'; +String altitudeLabel = 'Altitude'; String soundMeterError = 'Sound sensor error:'; String soundMeterInitialError = 'Sound sensor initialization error:'; String db = 'dB'; diff --git a/lib/main.dart b/lib/main.dart index 2e9bb17eb..78d94683a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'package:pslab/providers/board_state_provider.dart'; import 'package:pslab/providers/locator.dart'; import 'package:pslab/view/accelerometer_screen.dart'; +import 'package:pslab/view/barometer_screen.dart'; import 'package:pslab/view/connect_device_screen.dart'; import 'package:pslab/view/faq_screen.dart'; import 'package:pslab/view/gyroscope_screen.dart'; @@ -59,6 +60,7 @@ class MyApp extends StatelessWidget { '/gyroscope': (context) => const GyroscopeScreen(), '/roboticArm': (context) => const RoboticArmScreen(), '/luxmeter': (context) => const LuxMeterScreen(), + '/barometer': (context) => const BarometerScreen(), '/soundmeter': (context) => const SoundMeterScreen(), }, ); diff --git a/lib/providers/barometer_state_provider.dart b/lib/providers/barometer_state_provider.dart new file mode 100644 index 000000000..66d98cd51 --- /dev/null +++ b/lib/providers/barometer_state_provider.dart @@ -0,0 +1,164 @@ +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:sensors_plus/sensors_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:pslab/constants.dart'; + +class BarometerStateProvider extends ChangeNotifier { + double _currentPressure = 0.0; + StreamSubscription? _barometerSubscription; + Timer? _timeTimer; + final List _pressureData = []; + final List _timeData = []; + final List pressureChartData = []; + double _startTime = 0; + double _currentTime = 0; + final int _maxLength = 50; + double _pressureMin = 0; + double _pressureMax = 0; + double _pressureSum = 0; + int _dataCount = 0; + bool _sensorAvailable = false; + + Function(String)? onSensorError; + + void initializeSensors({Function(String)? onError}) { + onSensorError = onError; + + try { + _startTime = DateTime.now().millisecondsSinceEpoch / 1000.0; + _timeTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + _currentTime = + (DateTime.now().millisecondsSinceEpoch / 1000.0) - _startTime; + if (_sensorAvailable) { + _updateData(); + } + notifyListeners(); + }); + + Timer sensorTimeout = Timer(const Duration(seconds: 3), () { + if (!_sensorAvailable) { + _handleSensorError(barometerSensorError); + } + }); + + _barometerSubscription = barometerEventStream().listen( + (BarometerEvent event) { + _currentPressure = event.pressure / 1013.25; + if (!_sensorAvailable) { + _sensorAvailable = true; + sensorTimeout.cancel(); + } + notifyListeners(); + }, + onError: (error) { + logger.e("$barometerSensorError $error"); + sensorTimeout.cancel(); + _handleSensorError(error); + }, + cancelOnError: false, + ); + } catch (e) { + logger.e("$barometerSensorInitialError $e"); + _handleSensorError(e); + } + } + + void _handleSensorError(dynamic error) { + _sensorAvailable = false; + onSensorError?.call(barometerNotAvailable); + logger.e("$barometerSensorError $error"); + } + + void disposeSensors() { + _barometerSubscription?.cancel(); + _timeTimer?.cancel(); + } + + @override + void dispose() { + disposeSensors(); + super.dispose(); + } + + void _updateData() { + if (!_sensorAvailable) return; + + final pressure = _currentPressure; + final time = _currentTime; + _pressureData.add(pressure); + _timeData.add(time); + _pressureSum += pressure; + _dataCount++; + if (_pressureData.length > _maxLength) { + final removedValue = _pressureData.removeAt(0); + _timeData.removeAt(0); + _pressureSum -= removedValue; + _dataCount--; + } + if (_pressureData.isNotEmpty) { + _pressureMin = _pressureData.reduce(min); + _pressureMax = _pressureData.reduce(max); + } + pressureChartData.clear(); + for (int i = 0; i < _pressureData.length; i++) { + pressureChartData.add(FlSpot(_timeData[i], _pressureData[i])); + } + notifyListeners(); + } + + double _pressureToAltitude(double pressureAtm) { + const double seaLevelPressureAtm = 1.0; + const double temperatureK = 288.15; + const double lapseRate = 0.0065; + const double gasConstant = 287.05; + const double gravity = 9.80665; + + if (pressureAtm <= 0) return 0.0; + + double altitude = (temperatureK / lapseRate) * + (1 - + pow(pressureAtm / seaLevelPressureAtm, + (gasConstant * lapseRate) / gravity)); + + return altitude; + } + + double getCurrentPressure() => _currentPressure; + double getMinPressure() => _pressureMin; + double getMaxPressure() => _pressureMax; + double getAveragePressure() => + _dataCount > 0 ? _pressureSum / _dataCount : 0.0; + + double getCurrentAltitude() => _pressureToAltitude(_currentPressure); + double getMinAltitude() => + _pressureMin > 0 ? _pressureToAltitude(_pressureMin) : 0.0; + double getMaxAltitude() => + _pressureMax > 0 ? _pressureToAltitude(_pressureMax) : 0.0; + double getAverageAltitude() => _pressureToAltitude(getAveragePressure()); + + double getMaxAltitudeForChart() => + _pressureMax > 0 ? _pressureToAltitude(0) : 10000.0; + double getMinAltitudeForChart() => + _pressureMin > 0 ? _pressureToAltitude(_pressureMax * 1.1) : 0.0; + double getAltitudeInterval() { + double maxAlt = getMaxAltitudeForChart(); + return maxAlt > 0 ? (maxAlt / 5) : 2000; + } + + List getPressureChartData() => pressureChartData; + int getDataLength() => pressureChartData.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; + } + + bool get sensorAvailable => _sensorAvailable; +} diff --git a/lib/view/barometer_screen.dart b/lib/view/barometer_screen.dart new file mode 100644 index 000000000..47ba00693 --- /dev/null +++ b/lib/view/barometer_screen.dart @@ -0,0 +1,304 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:pslab/constants.dart'; +import 'package:pslab/providers/barometer_state_provider.dart'; +import 'package:pslab/theme/colors.dart'; +import 'package:pslab/view/widgets/common_scaffold_widget.dart'; +import 'package:pslab/view/widgets/barometer_card.dart'; +import 'package:fl_chart/fl_chart.dart'; + +class BarometerScreen extends StatefulWidget { + const BarometerScreen({super.key}); + @override + State createState() => _BarometerScreenState(); +} + +class _BarometerScreenState extends State { + void _showSensorErrorSnackbar(String message) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + message, + style: TextStyle(color: snackBarContentColor), + ), + backgroundColor: snackBarBackgroundColor, + duration: const Duration(seconds: 4), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => BarometerStateProvider() + ..initializeSensors(onError: _showSensorErrorSnackbar), + ), + ], + child: CommonScaffold( + title: barometerTitle, + body: SafeArea( + child: Column( + children: [ + const Expanded( + flex: 45, + child: BarometerCard(), + ), + 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; + final cardPadding = screenWidth < 400 ? 2.0 : 5.0; + List spots = provider.getPressureChartData(); + double maxPressure = provider.getMaxPressure(); + double maxTime = provider.getMaxTime(); + double minTime = provider.getMinTime(); + double timeInterval = provider.getTimeInterval(); + double maxAltitude = provider.getMaxAltitudeForChart(); + double minAltitude = provider.getMinAltitudeForChart(); + double altitudeInterval = provider.getAltitudeInterval(); + + return Container( + margin: EdgeInsets.fromLTRB(cardMargin, 0, cardMargin, cardMargin), + padding: EdgeInsets.all(cardPadding), + decoration: BoxDecoration( + color: chartBackgroundColor, + borderRadius: BorderRadius.zero, + ), + child: _buildChart( + screenWidth, + maxPressure, + maxTime, + minTime, + timeInterval, + spots, + maxAltitude, + minAltitude, + altitudeInterval)); + }, + ); + } + + 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: chartTextColor, + 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 altitudeTitleWidgets(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: chartTextColor, + fontSize: fontSize, + ); + + const double seaLevelPressureAtm = 1.0; + const double temperatureK = 288.15; + const double lapseRate = 0.0065; + const double gasConstant = 287.05; + const double gravity = 9.80665; + + double pressureAtm = value; + double altitude = 0.0; + + if (pressureAtm > 0) { + altitude = (temperatureK / lapseRate) * + (1 - + pow(pressureAtm / seaLevelPressureAtm, + (gasConstant * lapseRate) / gravity)); + } + + String altitudeText; + if (altitude < 1000) { + altitudeText = '${altitude.round()}'; + } else { + altitudeText = altitude.toStringAsFixed(0); + } + + return SideTitleWidget( + meta: meta, + child: Text( + altitudeText, + style: style, + ), + ); + } + + Widget _buildChart( + double screenWidth, + double maxPressure, + double maxTime, + double minTime, + double timeInterval, + List spots, + double maxAltitude, + double minAltitude, + double altitudeInterval) { + 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 ? 29.0 : 32.0; + final reservedSizeRight = screenWidth < 400 ? 29.0 : 32.0; + + return LineChart( + LineChartData( + backgroundColor: chartBackgroundColor, + titlesData: FlTitlesData( + show: true, + topTitles: AxisTitles( + axisNameWidget: Padding( + padding: EdgeInsets.only(left: screenWidth < 400 ? 15 : 25), + child: Text( + timeAxisLabel, + style: TextStyle( + fontSize: axisNameFontSize, + color: chartTextColor, + fontWeight: FontWeight.bold, + ), + ), + ), + axisNameSize: screenWidth < 400 ? 18 : 20, + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: reservedSizeBottom, + getTitlesWidget: sideTitleWidgets, + interval: timeInterval, + ), + ), + leftTitles: AxisTitles( + axisNameWidget: Text( + atm, + style: TextStyle( + fontSize: axisNameFontSize, + color: chartTextColor, + fontWeight: FontWeight.bold, + ), + ), + sideTitles: SideTitles( + reservedSize: reservedSizeLeft, + showTitles: true, + getTitlesWidget: (value, meta) { + return SideTitleWidget( + meta: meta, + child: Text( + value.toStringAsFixed(2), + style: TextStyle( + color: chartTextColor, + fontSize: chartFontSize, + ), + ), + ); + }, + interval: maxPressure > 0 ? (maxPressure / 5) : 0.2, + ), + ), + rightTitles: AxisTitles( + axisNameWidget: Text( + meterUnit, + style: TextStyle( + fontSize: axisNameFontSize, + color: chartTextColor, + fontWeight: FontWeight.bold, + ), + ), + sideTitles: SideTitles( + reservedSize: reservedSizeRight, + showTitles: true, + getTitlesWidget: (value, meta) => + altitudeTitleWidgets(value, meta), + interval: maxPressure > 0 ? (maxPressure / 5) : 0.2, + ), + ), + ), + gridData: FlGridData( + show: true, + drawHorizontalLine: true, + drawVerticalLine: true, + horizontalInterval: maxPressure > 0 ? (maxPressure / 5) : 0.2, + verticalInterval: timeInterval, + ), + borderData: FlBorderData( + show: true, + border: Border( + bottom: BorderSide(color: chartBorderColor), + left: BorderSide(color: chartBorderColor), + top: BorderSide(color: chartBorderColor), + right: BorderSide(color: chartBorderColor), + ), + ), + minY: 0, + maxY: maxPressure > 0 ? (maxPressure * 1.1) : 2.0, + maxX: maxTime > 0 ? maxTime : 10, + minX: minTime, + clipData: const FlClipData.all(), + lineBarsData: [ + LineChartBarData( + spots: spots, + isCurved: true, + color: xOrientationChartLineColor, + barWidth: screenWidth < 400 ? 1.5 : 2.0, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + ], + ), + ); + } +} diff --git a/lib/view/instruments_screen.dart b/lib/view/instruments_screen.dart index 72c8ebe34..80cb3c46a 100644 --- a/lib/view/instruments_screen.dart +++ b/lib/view/instruments_screen.dart @@ -40,6 +40,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 7: if (Navigator.canPop(context) && ModalRoute.of(context)?.settings.name == '/accelerometer') { @@ -52,14 +64,14 @@ class _InstrumentsScreenState extends State { ); } break; - case 6: + case 8: if (Navigator.canPop(context) && - ModalRoute.of(context)?.settings.name == '/luxmeter') { - Navigator.popUntil(context, ModalRoute.withName('/luxmeter')); + ModalRoute.of(context)?.settings.name == '/barometer') { + Navigator.popUntil(context, ModalRoute.withName('/barometer')); } else { Navigator.pushNamedAndRemoveUntil( context, - '/luxmeter', + '/barometer', (route) => route.isFirst, ); } diff --git a/lib/view/widgets/barometer_card.dart b/lib/view/widgets/barometer_card.dart new file mode 100644 index 000000000..594becaa6 --- /dev/null +++ b/lib/view/widgets/barometer_card.dart @@ -0,0 +1,133 @@ +import 'package:pslab/view/widgets/gauge_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:pslab/providers/barometer_state_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:pslab/view/widgets/instruments_stats.dart'; +import 'package:pslab/constants.dart'; + +import '../../theme/colors.dart'; + +class BarometerCard extends StatefulWidget { + const BarometerCard({super.key}); + @override + State createState() => _BarometerCardState(); +} + +class _BarometerCardState extends State { + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final isLargeScreen = screenWidth > 900; + BarometerStateProvider provider = + Provider.of(context); + double currentPressure = provider.getCurrentPressure(); + double minPressure = provider.getMinPressure(); + double maxPressure = provider.getMaxPressure(); + double avgPressure = provider.getAveragePressure(); + double currentAltitude = provider.getCurrentAltitude(); + 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 ? 15.0 : 10.0; + final pressureValueFontSize = isLargeScreen ? 20.0 : 16.0; + + return Card( + margin: EdgeInsets.all(cardMargin), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 1, + child: Container( + decoration: BoxDecoration( + color: cardBackgroundColor, + 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: Column( + children: [ + Expanded( + flex: 75, + child: Instrumentstats( + titleFontSize: titleFontSize, + statFontSize: statFontSize, + maxValue: maxPressure, + minValue: minPressure, + avgValue: avgPressure, + unit: atm, + ), + ), + Expanded( + flex: 25, + child: + _buildAltitudeTile(currentAltitude, statFontSize), + ), + ], + ), + ), + Expanded( + flex: screenWidth < 500 ? 60 : 65, + child: GaugeWidget( + gaugeSize: gaugeSize, + currentValue: currentPressure, + minValue: 0, + maxValue: 2, + unit: atm, + currentValueFontSize: pressureValueFontSize), + ), + ], + ); + }, + ), + ), + ), + ); + } + + Widget _buildAltitudeTile(double currentAltitude, double fontSize) { + final screenWidth = MediaQuery.of(context).size.width; + final padding = screenWidth < 400 ? 15.0 : 20.0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Text( + '$altitudeLabel ($meterUnit)', + style: TextStyle( + color: cardContentColor, + 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: instrumentStatBoxColor), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + currentAltitude.toStringAsFixed(2), + style: TextStyle( + color: cardContentColor, + fontSize: fontSize, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/view/widgets/instruments_stats.dart b/lib/view/widgets/instruments_stats.dart index 27b89acc6..183e11e7c 100644 --- a/lib/view/widgets/instruments_stats.dart +++ b/lib/view/widgets/instruments_stats.dart @@ -75,7 +75,6 @@ class StatItem extends StatelessWidget { @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( @@ -104,7 +103,7 @@ class StatItem extends StatelessWidget { value.toStringAsFixed(2), style: TextStyle( color: cardContentColor, - fontSize: valueFontSize, + fontSize: fontSize, fontWeight: FontWeight.bold, ), ),