diff --git a/assets/images/bmp180.jpg b/assets/images/bmp180.jpg new file mode 100644 index 000000000..04d88c01f Binary files /dev/null and b/assets/images/bmp180.jpg differ diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 70f8d4992..134738cff 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -362,5 +362,27 @@ "accelerometerUpdatePeriodHint": "Please provide time interval at which data will be updated", "accelerometerHighLimitHint": "Please provide the maximum limit of lux value to be recorded", "roboticArmIntro": "• A robotic arm is a programmable mechanical device that mimics the movement of a human arm.\n• It uses servo motors to control its motion, and these motors are operated using PWM signals.\n• The PSLab provides four PWM square wave generators (SQ1, SQ2, SQ3, SQ4), allowing control of up to four servo motors and enabling a robotic arm with up to four degrees of freedom.", - "roboticArmConnection": "• In the above figure, SQ1 is connected to the signal pin of the first servo motor. The servo's GND pin is connected to both the PSLab’s GND and the external power supply GND, while the VCC pin is connected to the external power supply VCC.\n• Similarly, connect the remaining servos to SQ2, SQ3, and SQ4 along with their respective GND and power supply connections.\n• Once connected, each servo can be controlled using either circular sliders for manual control or a timeline-based sequence for automated movement." + "roboticArmConnection": "• In the above figure, SQ1 is connected to the signal pin of the first servo motor. The servo's GND pin is connected to both the PSLab’s GND and the external power supply GND, while the VCC pin is connected to the external power supply VCC.\n• Similarly, connect the remaining servos to SQ2, SQ3, and SQ4 along with their respective GND and power supply connections.\n• Once connected, each servo can be controlled using either circular sliders for manual control or a timeline-based sequence for automated movement.", + "autoscan" : "Autoscan", + "selectSensor" : "Select Sensor", + "notConnected" : "Not Connected", + "autoScanHint" : "Use Autoscan button to find connected sensors to PSLab device", + "noSensorDetected" : "No sensors detected", + "screenNotImplemented" : "screen not implemented yet", + "timeGap" : "Time gap", + "pslabNotConnected" : "PSLab not connected", + "clearData" : "Clear Data", + "numberOfSampes" : "No. of samples", + "pressure" : "Pressure", + "temperature" : "Temperature", + "bmp180" : "BMP180", + "plot" : "Plot", + "dataCleared" : "Data cleared successfully", + "rawData" : "Raw Data", + "pressureUnitLabel" : "Pa", + "temperatureUnitLabel" : "°C", + "altitudeUnitLabel" : "m", + "time" : "Time", + "notAvailable" : "N/A", + "estimated" : "Estimated" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3e2b0d4d3..bb89af746 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2271,6 +2271,138 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'• In the above figure, SQ1 is connected to the signal pin of the first servo motor. The servo\'s GND pin is connected to both the PSLab’s GND and the external power supply GND, while the VCC pin is connected to the external power supply VCC.\n• Similarly, connect the remaining servos to SQ2, SQ3, and SQ4 along with their respective GND and power supply connections.\n• Once connected, each servo can be controlled using either circular sliders for manual control or a timeline-based sequence for automated movement.'** String get roboticArmConnection; + + /// No description provided for @autoscan. + /// + /// In en, this message translates to: + /// **'Autoscan'** + String get autoscan; + + /// No description provided for @selectSensor. + /// + /// In en, this message translates to: + /// **'Select Sensor'** + String get selectSensor; + + /// No description provided for @notConnected. + /// + /// In en, this message translates to: + /// **'Not Connected'** + String get notConnected; + + /// No description provided for @autoScanHint. + /// + /// In en, this message translates to: + /// **'Use Autoscan button to find connected sensors to PSLab device'** + String get autoScanHint; + + /// No description provided for @noSensorDetected. + /// + /// In en, this message translates to: + /// **'No sensors detected'** + String get noSensorDetected; + + /// No description provided for @screenNotImplemented. + /// + /// In en, this message translates to: + /// **'screen not implemented yet'** + String get screenNotImplemented; + + /// No description provided for @timeGap. + /// + /// In en, this message translates to: + /// **'Time gap'** + String get timeGap; + + /// No description provided for @pslabNotConnected. + /// + /// In en, this message translates to: + /// **'PSLab not connected'** + String get pslabNotConnected; + + /// No description provided for @clearData. + /// + /// In en, this message translates to: + /// **'Clear Data'** + String get clearData; + + /// No description provided for @numberOfSampes. + /// + /// In en, this message translates to: + /// **'No. of samples'** + String get numberOfSampes; + + /// No description provided for @pressure. + /// + /// In en, this message translates to: + /// **'Pressure'** + String get pressure; + + /// No description provided for @temperature. + /// + /// In en, this message translates to: + /// **'Temperature'** + String get temperature; + + /// No description provided for @bmp180. + /// + /// In en, this message translates to: + /// **'BMP180'** + String get bmp180; + + /// No description provided for @plot. + /// + /// In en, this message translates to: + /// **'Plot'** + String get plot; + + /// No description provided for @dataCleared. + /// + /// In en, this message translates to: + /// **'Data cleared successfully'** + String get dataCleared; + + /// No description provided for @rawData. + /// + /// In en, this message translates to: + /// **'Raw Data'** + String get rawData; + + /// No description provided for @pressureUnitLabel. + /// + /// In en, this message translates to: + /// **'Pa'** + String get pressureUnitLabel; + + /// No description provided for @temperatureUnitLabel. + /// + /// In en, this message translates to: + /// **'°C'** + String get temperatureUnitLabel; + + /// No description provided for @altitudeUnitLabel. + /// + /// In en, this message translates to: + /// **'m'** + String get altitudeUnitLabel; + + /// No description provided for @time. + /// + /// In en, this message translates to: + /// **'Time'** + String get time; + + /// No description provided for @notAvailable. + /// + /// In en, this message translates to: + /// **'N/A'** + String get notAvailable; + + /// No description provided for @estimated. + /// + /// In en, this message translates to: + /// **'Estimated'** + String get estimated; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a74ef5196..3aa1fa22a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1165,4 +1165,71 @@ class AppLocalizationsEn extends AppLocalizations { @override String get roboticArmConnection => '• In the above figure, SQ1 is connected to the signal pin of the first servo motor. The servo\'s GND pin is connected to both the PSLab’s GND and the external power supply GND, while the VCC pin is connected to the external power supply VCC.\n• Similarly, connect the remaining servos to SQ2, SQ3, and SQ4 along with their respective GND and power supply connections.\n• Once connected, each servo can be controlled using either circular sliders for manual control or a timeline-based sequence for automated movement.'; + + @override + String get autoscan => 'Autoscan'; + + @override + String get selectSensor => 'Select Sensor'; + + @override + String get notConnected => 'Not Connected'; + + @override + String get autoScanHint => + 'Use Autoscan button to find connected sensors to PSLab device'; + + @override + String get noSensorDetected => 'No sensors detected'; + + @override + String get screenNotImplemented => 'screen not implemented yet'; + + @override + String get timeGap => 'Time gap'; + + @override + String get pslabNotConnected => 'PSLab not connected'; + + @override + String get clearData => 'Clear Data'; + + @override + String get numberOfSampes => 'No. of samples'; + + @override + String get pressure => 'Pressure'; + + @override + String get temperature => 'Temperature'; + + @override + String get bmp180 => 'BMP180'; + + @override + String get plot => 'Plot'; + + @override + String get dataCleared => 'Data cleared successfully'; + + @override + String get rawData => 'Raw Data'; + + @override + String get pressureUnitLabel => 'Pa'; + + @override + String get temperatureUnitLabel => '°C'; + + @override + String get altitudeUnitLabel => 'm'; + + @override + String get time => 'Time'; + + @override + String get notAvailable => 'N/A'; + + @override + String get estimated => 'Estimated'; } diff --git a/lib/main.dart b/lib/main.dart index f5e6e0a69..d5d8b7337 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'package:pslab/view/multimeter_screen.dart'; import 'package:pslab/view/oscilloscope_screen.dart'; import 'package:pslab/view/power_source_screen.dart'; import 'package:pslab/view/robotic_arm_screen.dart'; +import 'package:pslab/view/sensors_screen.dart'; import 'package:pslab/view/settings_screen.dart'; import 'package:pslab/view/about_us_screen.dart'; import 'package:pslab/view/software_licenses_screen.dart'; @@ -75,6 +76,7 @@ class MyApp extends StatelessWidget { '/luxmeter': (context) => const LuxMeterScreen(), '/barometer': (context) => const BarometerScreen(), '/soundmeter': (context) => const SoundMeterScreen(), + '/sensors': (context) => const SensorsScreen() }, ); } diff --git a/lib/models/chart_data_points.dart b/lib/models/chart_data_points.dart new file mode 100644 index 000000000..0ff8e773c --- /dev/null +++ b/lib/models/chart_data_points.dart @@ -0,0 +1,42 @@ +class ChartDataPoint { + final double x; + final double y; + + ChartDataPoint(this.x, this.y); + + @override + String toString() => 'ChartDataPoint(x: $x, y: $y)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ChartDataPoint && other.x == x && other.y == y; + } + + @override + int get hashCode => Object.hash(x, y); + + factory ChartDataPoint.fromMap(Map map) { + return ChartDataPoint( + map['x']?.toDouble() ?? 0.0, + map['y']?.toDouble() ?? 0.0, + ); + } + + Map toMap() { + return { + 'x': x, + 'y': y, + }; + } + + ChartDataPoint copyWith({ + double? x, + double? y, + }) { + return ChartDataPoint( + x ?? this.x, + y ?? this.y, + ); + } +} diff --git a/lib/providers/bmp180_provider.dart b/lib/providers/bmp180_provider.dart new file mode 100644 index 000000000..dd4b5489a --- /dev/null +++ b/lib/providers/bmp180_provider.dart @@ -0,0 +1,200 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:pslab/communication/peripherals/i2c.dart'; +import 'package:pslab/communication/science_lab.dart'; +import '../communication/sensors/bmp180.dart'; +import '../l10n/app_localizations.dart'; +import '../models/chart_data_points.dart'; +import 'package:pslab/others/logger_service.dart'; +import 'locator.dart'; + +class BMP180Provider extends ChangeNotifier { + AppLocalizations appLocalizations = getIt.get(); + + BMP180? _bmp180; + Timer? _dataTimer; + + double _temperature = 0.0; + double _pressure = 0.0; + double _altitude = 0.0; + + final List _temperatureData = []; + final List _pressureData = []; + final List _altitudeData = []; + + bool _isRunning = false; + bool _isLooping = false; + int _timegapMs = 1000; + int _numberOfReadings = 100; + int _collectedReadings = 0; + + double _currentTime = 0.0; + static const int maxDataPoints = 1000; + + double get temperature => _temperature; + double get pressure => _pressure; + double get altitude => _altitude; + + List get temperatureData => + List.unmodifiable(_temperatureData); + List get pressureData => List.unmodifiable(_pressureData); + List get altitudeData => List.unmodifiable(_altitudeData); + + bool get isRunning => _isRunning; + bool get isLooping => _isLooping; + int get timegapMs => _timegapMs; + int get numberOfReadings => _numberOfReadings; + int get collectedReadings => _collectedReadings; + + BMP180Provider(); + + Future initializeSensors({ + required Function(String) onError, + required I2C? i2c, + required ScienceLab? scienceLab, + }) async { + try { + if (i2c == null || scienceLab == null) { + onError(appLocalizations.pslabNotConnected); + logger.w('I2C or ScienceLab not available'); + return; + } + + if (!scienceLab.isConnected()) { + onError(appLocalizations.pslabNotConnected); + logger.w("Sciencelab not connected"); + return; + } + + _bmp180 = await BMP180.create(i2c, scienceLab); + notifyListeners(); + } catch (e) { + logger.e('Error initializing BMP180: $e'); + } + } + + void toggleDataCollection() { + if (_isRunning) { + _stopDataCollection(); + } else { + _startDataCollection(); + } + } + + void _startDataCollection() { + if (_bmp180 == null) return; + + _isRunning = true; + _collectedReadings = 0; + + _dataTimer = + Timer.periodic(Duration(milliseconds: _timegapMs), (timer) async { + try { + await _fetchSensorData(); + _collectedReadings++; + + if (!_isLooping && _collectedReadings >= _numberOfReadings) { + _stopDataCollection(); + } + + if (_isLooping && _temperatureData.length >= maxDataPoints) { + _removeOldestDataPoints(); + } + } catch (e) { + logger.e('Error fetching sensor data: $e'); + } + }); + notifyListeners(); + } + + void _stopDataCollection() { + _isRunning = false; + _dataTimer?.cancel(); + _dataTimer = null; + notifyListeners(); + } + + Future _fetchSensorData() async { + if (_bmp180 == null) return; + + try { + final rawData = await _bmp180!.getRawData(); + + _temperature = rawData['temperature'] ?? 0.0; + _pressure = rawData['pressure'] ?? 0.0; + _altitude = rawData['altitude'] ?? 0.0; + + _currentTime += _timegapMs / 1000.0; + + _addDataPoint(_temperatureData, _temperature); + _addDataPoint(_pressureData, _pressure); + _addDataPoint(_altitudeData, _altitude); + + notifyListeners(); + } catch (e) { + logger.e('Error in _fetchSensorData: $e'); + rethrow; + } + } + + void _addDataPoint(List dataList, double value) { + dataList.add(ChartDataPoint(_currentTime, value)); + if (dataList.length > 50) { + dataList.removeAt(0); + } + } + + void _removeOldestDataPoints() { + const keepPoints = 800; + + if (_temperatureData.length > keepPoints) { + final removeCount = _temperatureData.length - keepPoints; + _temperatureData.removeRange(0, removeCount); + _pressureData.removeRange(0, removeCount); + _altitudeData.removeRange(0, removeCount); + } + } + + void toggleLooping() { + _isLooping = !_isLooping; + notifyListeners(); + } + + void setTimegap(int timegapMs) { + _timegapMs = timegapMs; + + if (_isRunning) { + _stopDataCollection(); + _startDataCollection(); + } + + notifyListeners(); + } + + void setNumberOfReadings(int numberOfReadings) { + _numberOfReadings = numberOfReadings; + notifyListeners(); + } + + void clearData() { + _temperatureData.clear(); + _pressureData.clear(); + _altitudeData.clear(); + _pressure = 0; + _altitude = 0; + _temperature = 0; + _currentTime = 0.0; + _collectedReadings = 0; + notifyListeners(); + } + + bool get isCollectionComplete { + return !_isLooping && _collectedReadings >= _numberOfReadings; + } + + @override + void dispose() { + _stopDataCollection(); + super.dispose(); + } +} diff --git a/lib/theme/colors.dart b/lib/theme/colors.dart index 2af52f35e..b0f3db96c 100644 --- a/lib/theme/colors.dart +++ b/lib/theme/colors.dart @@ -18,7 +18,7 @@ Color dividerColor = Colors.grey; Color usbConnectionColor = Colors.blueGrey[600]!; Color buttonForegroundColor = Colors.white; Color buttonTextColor = Colors.white; -Color chartLineColor = Colors.cyan; +const Color chartLineColor = Colors.cyan; Color chartBorderColor = Colors.white38; Color chartBackgroundColor = Colors.black; Color chartTextColor = Colors.white; @@ -78,3 +78,9 @@ Color logicAnalyzerTextColor = Colors.white; Color logicAnalyzerGraphTextColor = Colors.amber; Color powerSourceBorderLightRed = Color.fromARGB(255, 240, 162, 162); Color powerSourceKnobColor = Colors.grey.shade100; +Color sensorStatusBackgroundColor = Colors.grey[100]!; +Color sensorStatusBorder = Colors.grey[300]!; +Color sensorControlsTextBox = Colors.grey.shade400; +Color sensorControlIconColor = Colors.grey.shade600; +List bmp180ChartColors = [Colors.blue, Colors.green, Colors.red]; +Color chartHintTextColor = Colors.yellow; diff --git a/lib/view/bmp180_screen.dart b/lib/view/bmp180_screen.dart new file mode 100644 index 000000000..835b247b5 --- /dev/null +++ b/lib/view/bmp180_screen.dart @@ -0,0 +1,315 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:pslab/view/widgets/common_scaffold_widget.dart'; +import 'package:pslab/view/widgets/sensor_controls.dart'; +import 'package:pslab/communication/peripherals/i2c.dart'; +import 'package:pslab/communication/science_lab.dart'; +import 'package:pslab/providers/locator.dart'; +import 'package:pslab/others/logger_service.dart'; +import '../l10n/app_localizations.dart'; +import '../theme/colors.dart'; +import 'widgets/sensor_chart_widget.dart'; +import '../providers/bmp180_provider.dart'; + +class BMP180Screen extends StatefulWidget { + const BMP180Screen({super.key}); + + @override + State createState() => _BMP180ScreenState(); +} + +class _BMP180ScreenState extends State { + AppLocalizations appLocalizations = getIt.get(); + String sensorImage = 'assets/images/bmp180.jpg'; + I2C? _i2c; + ScienceLab? _scienceLab; + late BMP180Provider _provider; + + @override + void initState() { + super.initState(); + _initializeScienceLab(); + } + + void _initializeScienceLab() async { + try { + _scienceLab = getIt.get(); + if (_scienceLab != null && _scienceLab!.isConnected()) { + _i2c = I2C(_scienceLab!.mPacketHandler); + } + } catch (e) { + logger.e('Error initializing ScienceLab: $e'); + } + } + + void _showSensorErrorSnackbar(String message) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + message, + style: TextStyle(color: snackBarContentColor), + ), + backgroundColor: snackBarBackgroundColor, + duration: const Duration(milliseconds: 500), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + void _showSuccessSnackbar(String message) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + message, + style: TextStyle(color: snackBarContentColor), + ), + backgroundColor: snackBarBackgroundColor, + duration: const Duration(milliseconds: 500), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (context) { + _provider = BMP180Provider() + ..initializeSensors( + onError: _showSensorErrorSnackbar, + i2c: _i2c, + scienceLab: _scienceLab, + ); + return _provider; + }, + child: Consumer( + builder: (context, provider, child) { + return CommonScaffold( + title: appLocalizations.bmp180, + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRawDataSection(provider), + const SizedBox(height: 24), + SensorChartWidget( + title: + '${appLocalizations.plot} - ${appLocalizations.temperature}', + yAxisLabel: + '${appLocalizations.temperature} (${appLocalizations.temperatureUnitLabel})', + data: provider.temperatureData, + lineColor: bmp180ChartColors[0], + unit: appLocalizations.temperatureUnitLabel, + maxDataPoints: provider.numberOfReadings, + showDots: true, + ), + const SizedBox(height: 20), + SensorChartWidget( + title: + '${appLocalizations.plot} - ${appLocalizations.estimated} ${appLocalizations.altitudeLabel}', + yAxisLabel: + '${appLocalizations.altitudeLabel} (${appLocalizations.altitudeUnitLabel})', + data: provider.altitudeData, + lineColor: bmp180ChartColors[1], + unit: appLocalizations.altitudeUnitLabel, + maxDataPoints: provider.numberOfReadings, + showDots: true, + ), + const SizedBox(height: 20), + SensorChartWidget( + title: + '${appLocalizations.plot} - ${appLocalizations.pressure}', + yAxisLabel: + '${appLocalizations.pressure} (${appLocalizations.pressureUnitLabel})', + data: provider.pressureData, + lineColor: bmp180ChartColors[2], + unit: appLocalizations.pressureUnitLabel, + maxDataPoints: provider.numberOfReadings, + showDots: true, + ), + const SizedBox(height: 100), + ], + ), + ), + ), + SensorControlsWidget( + isPlaying: provider.isRunning, + isLooping: provider.isLooping, + timegapMs: provider.timegapMs, + numberOfReadings: provider.numberOfReadings, + onPlayPause: () { + provider.toggleDataCollection(); + }, + onLoop: provider.toggleLooping, + onTimegapChanged: provider.setTimegap, + onNumberOfReadingsChanged: provider.setNumberOfReadings, + onClearData: () { + provider.clearData(); + _showSuccessSnackbar(appLocalizations.dataCleared); + }, + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildRawDataSection(BMP180Provider provider) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: cardBackgroundColor, + borderRadius: BorderRadius.zero, + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(50), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), + decoration: BoxDecoration( + color: primaryRed, + borderRadius: const BorderRadius.only( + topLeft: Radius.zero, + topRight: Radius.zero, + ), + ), + child: Row( + children: [ + Text( + appLocalizations.rawData, + style: TextStyle( + color: appBarContentColor, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (provider.isRunning) + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: appBarContentColor, + shape: BoxShape.circle, + ), + ), + ], + ), + ), + Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Column( + children: [ + _buildDataCard( + appLocalizations.temperature, + provider.temperature.toStringAsFixed(2), + ), + const SizedBox(height: 16), + _buildDataCard( + appLocalizations.altitudeLabel, + provider.altitude.toStringAsFixed(2), + ), + const SizedBox(height: 16), + _buildDataCard( + appLocalizations.pressure, + provider.pressure.toStringAsFixed(0), + ), + ], + ), + ), + const SizedBox(width: 24), + SizedBox( + width: 80, + height: 80, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.asset( + sensorImage, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Icon( + Icons.sensors, + size: 40, + color: sensorControlsTextBox, + ); + }, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDataCard(String label, String value) { + return Row( + children: [ + SizedBox( + width: 100, + child: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: blackTextColor, + ), + ), + ), + Expanded( + flex: 3, + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + border: Border.all(color: sensorControlsTextBox), + borderRadius: BorderRadius.circular(4), + color: cardBackgroundColor, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + value, + style: TextStyle( + fontSize: 14, + color: blackTextColor, + ), + ), + ), + ), + ), + ], + ); + } + + @override + void dispose() { + _provider.dispose(); + super.dispose(); + } +} diff --git a/lib/view/instruments_screen.dart b/lib/view/instruments_screen.dart index fa27d6bde..68bee8199 100644 --- a/lib/view/instruments_screen.dart +++ b/lib/view/instruments_screen.dart @@ -57,6 +57,18 @@ class _InstrumentsScreenState extends State { ); } break; + case 3: + if (Navigator.canPop(context) && + ModalRoute.of(context)?.settings.name == '/sensors') { + Navigator.popUntil(context, ModalRoute.withName('/sensors')); + } else { + Navigator.pushNamedAndRemoveUntil( + context, + '/sensors', + (route) => route.isFirst, + ); + } + break; case 5: if (Navigator.canPop(context) && ModalRoute.of(context)?.settings.name == '/powerSource') { diff --git a/lib/view/sensors_screen.dart b/lib/view/sensors_screen.dart new file mode 100644 index 000000000..2c4876fe4 --- /dev/null +++ b/lib/view/sensors_screen.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:pslab/view/bmp180_screen.dart'; +import 'package:pslab/view/widgets/common_scaffold_widget.dart'; +import '../../providers/board_state_provider.dart'; +import '../l10n/app_localizations.dart'; +import '../providers/locator.dart'; +import '../theme/colors.dart'; + +class SensorsScreen extends StatefulWidget { + const SensorsScreen({super.key}); + + @override + State createState() => _SensorsScreenState(); +} + +class _SensorsScreenState extends State { + AppLocalizations appLocalizations = getIt.get(); + bool _hasScanned = false; + List _detectedSensors = []; + Map _sensorAddresses = {}; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, boardProvider, child) { + return CommonScaffold( + title: appLocalizations.sensors, + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: () { + _performAutoscan(boardProvider); + }, + style: ElevatedButton.styleFrom( + backgroundColor: primaryRed, + foregroundColor: buttonTextColor, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + child: Text( + appLocalizations.autoscan.toUpperCase(), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + const SizedBox(height: 20), + Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + decoration: BoxDecoration( + color: sensorStatusBackgroundColor, + borderRadius: BorderRadius.circular(25), + border: Border.all( + color: sensorStatusBorder, + width: 1, + ), + ), + child: Text( + _getStatusText(boardProvider), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: blackTextColor, + ), + ), + ), + if (_hasScanned) ...[ + const SizedBox(height: 30), + Text( + appLocalizations.selectSensor.toUpperCase(), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: blackTextColor, + ), + ), + const SizedBox(height: 20), + Expanded( + child: _buildSensorList(), + ), + ], + ], + ), + ), + ); + }, + ); + } + + String _getStatusText(BoardStateProvider boardProvider) { + if (!boardProvider.pslabIsConnected) { + return appLocalizations.notConnected; + } + + if (!_hasScanned) { + return appLocalizations.autoScanHint; + } + + if (_detectedSensors.isEmpty) { + return appLocalizations.noSensorDetected; + } + + String result = ''; + for (String sensor in _detectedSensors) { + String address = _sensorAddresses[sensor] ?? ''; + result += '$address: [$sensor]\n'; + } + return result.trim(); + } + + void _performAutoscan(BoardStateProvider boardProvider) { + setState(() { + _hasScanned = true; + + if (boardProvider.pslabIsConnected) { + _detectedSensors = [ + 'HMC5883L', + 'VL53L0X', + 'TSL2561', + 'APDS9960', + 'SHT21', + 'ADS1115', + 'MLX90614', + 'CCS811', + 'MPU6050', + 'MPU925X', + 'BMP180', + ]; + _sensorAddresses = { + 'HMC5883L': '30', + 'VL53L0X': '41', + 'TSL2561': '57', + 'APDS9960': '57', + 'SHT21': '64', + 'ADS1115': '72', + 'MLX90614': '90', + 'CCS811': '90', + 'MPU6050': '104', + 'MPU925X': '105', + 'BMP180': '119', + }; + } else { + _detectedSensors = []; + _sensorAddresses = {}; + } + }); + } + + Widget _buildSensorList() { + final sensors = [ + 'ADS1115', + 'APDS9960', + 'BMP180', + 'CCS811', + 'HMC5883L', + 'MLX90614', + 'MPU6050', + 'MPU925X', + 'SHT21', + 'TSL2561', + 'VL53L0X', + ]; + + return ListView.builder( + itemCount: sensors.length, + itemBuilder: (context, index) { + final sensor = sensors[index]; + final isDetected = _detectedSensors.contains(sensor); + + return Container( + margin: const EdgeInsets.only(bottom: 1), + child: Material( + color: primaryRed, + child: InkWell( + onTap: () { + _onSensorTap(sensor); + }, + child: Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(vertical: 18, horizontal: 16), + decoration: BoxDecoration( + color: primaryRed, + border: isDetected + ? Border.all(color: buttonTextColor, width: 2) + : null, + ), + child: Text( + sensor, + textAlign: TextAlign.center, + style: TextStyle( + color: buttonTextColor, + fontSize: 16, + fontWeight: isDetected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ), + ), + ); + }, + ); + } + + void _onSensorTap(String sensorName) { + Widget? targetScreen; + + switch (sensorName) { + case 'BMP180': + targetScreen = const BMP180Screen(); + break; + default: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text('$sensorName ${appLocalizations.screenNotImplemented}'), + duration: const Duration(milliseconds: 500), + ), + ); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => targetScreen!, + ), + ); + } +} diff --git a/lib/view/widgets/sensor_chart_widget.dart b/lib/view/widgets/sensor_chart_widget.dart new file mode 100644 index 000000000..c257100c7 --- /dev/null +++ b/lib/view/widgets/sensor_chart_widget.dart @@ -0,0 +1,358 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../../l10n/app_localizations.dart'; +import '../../models/chart_data_points.dart'; +import '../../providers/locator.dart'; +import '../../theme/colors.dart'; + +AppLocalizations appLocalizations = getIt.get(); + +class SensorChartWidget extends StatelessWidget { + final String title; + final String yAxisLabel; + final String? xAxisLabel; + final List data; + final Color lineColor; + final Color? backgroundColor; + final double? minY; + final double? maxY; + final double? minX; + final double? maxX; + final bool showGrid; + final bool showDots; + final bool isCurved; + final double lineWidth; + final String? unit; + final int? maxDataPoints; + final Widget? customNoDataWidget; + const SensorChartWidget({ + super.key, + required this.title, + required this.yAxisLabel, + required this.data, + this.xAxisLabel = 'Time (s)', + this.lineColor = chartLineColor, + this.backgroundColor, + this.minY, + this.maxY, + this.minX, + this.maxX, + this.showGrid = true, + this.showDots = false, + this.isCurved = true, + this.lineWidth = 2.0, + this.unit, + this.maxDataPoints, + this.customNoDataWidget, + }); + List get _validData { + return data.where((point) { + return point.x.isFinite && + point.y.isFinite && + !point.x.isNaN && + !point.y.isNaN; + }).toList(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: Column( + children: [ + _buildHeader(), + _buildChart(), + ], + ), + ); + } + + Widget _buildHeader() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: primaryRed, + borderRadius: const BorderRadius.only( + topLeft: Radius.zero, + topRight: Radius.zero, + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: TextStyle( + color: chartTextColor, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + Widget _buildChart() { + return Container( + width: double.infinity, + height: 220, + decoration: BoxDecoration( + color: backgroundColor ?? chartBackgroundColor, + border: Border.all(color: chartTextColor), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.zero, + bottomRight: Radius.zero, + ), + ), + child: _validData.isEmpty ? _buildNoDataView() : _buildLineChart(), + ); + } + + Widget _buildNoDataView() { + return Stack( + children: [ + _buildAxisLabels(), + Center( + child: customNoDataWidget ?? + Text( + appLocalizations.noData, + style: TextStyle( + color: chartHintTextColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + } + + Widget _buildLineChart() { + if (_validData.isEmpty) { + return _buildNoDataView(); + } + return Stack( + children: [ + _buildAxisLabels(), + Padding( + padding: + const EdgeInsets.only(left: 50, right: 16, top: 16, bottom: 40), + child: LineChart( + LineChartData( + backgroundColor: Colors.transparent, + gridData: FlGridData( + show: showGrid, + drawVerticalLine: showGrid, + drawHorizontalLine: showGrid, + horizontalInterval: _calculateGridInterval(), + getDrawingHorizontalLine: (value) => FlLine( + color: Colors.grey.withAlpha(77), + strokeWidth: 0.8, + ), + getDrawingVerticalLine: (value) => FlLine( + color: Colors.grey.withAlpha(77), + strokeWidth: 0.8, + ), + ), + titlesData: FlTitlesData(show: false), + borderData: FlBorderData( + show: true, + border: Border.all( + color: Colors.grey.withAlpha(120), + width: 1, + ), + ), + minX: _getMinX(), + maxX: _getMaxX(), + minY: _getMinY(), + maxY: _getMaxY(), + lineBarsData: [ + LineChartBarData( + spots: _validData + .map((point) => FlSpot(point.x, point.y)) + .toList(), + isCurved: isCurved, + color: lineColor, + barWidth: lineWidth, + isStrokeCapRound: true, + dotData: FlDotData( + show: showDots, + getDotPainter: (spot, percent, barData, index) { + return FlDotCirclePainter( + radius: 2, + color: lineColor, + strokeWidth: 1, + strokeColor: chartTextColor, + ); + }, + ), + belowBarData: BarAreaData( + show: false, + color: lineColor.withAlpha(26), + ), + ), + ], + lineTouchData: LineTouchData( + enabled: true, + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (touchedSpots) { + return touchedSpots.map((spot) { + final yValue = spot.y.isFinite + ? spot.y.toStringAsFixed(2) + : appLocalizations.notAvailable; + final xValue = spot.x.isFinite + ? spot.x.toStringAsFixed(1) + : appLocalizations.notAvailable; + return LineTooltipItem( + '$yAxisLabel: $yValue${unit ?? ''}\n${appLocalizations.time}: $xValue', + TextStyle( + color: chartTextColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ); + }).toList(); + }, + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildAxisLabels() { + return Stack( + children: [ + Positioned( + bottom: 12, + left: 0, + right: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withAlpha(180), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + appLocalizations.timeAxisLabel, + style: TextStyle( + color: chartTextColor, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + Positioned( + left: 12, + top: 0, + bottom: 0, + child: Center( + child: RotatedBox( + quarterTurns: 3, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withAlpha(180), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + yAxisLabel, + style: TextStyle( + color: chartTextColor, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + if (_validData.isNotEmpty) + Positioned( + top: 12, + right: 20, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: lineColor.withAlpha(230), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${_validData.last.y.toStringAsFixed(2)}${unit ?? ''}', + style: TextStyle( + color: chartTextColor, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ); + } + + double _getMinX() { + if (minX != null) return minX!; + if (_validData.isEmpty) return 0; + return _validData.first.x; + } + + double _getMaxX() { + if (maxX != null) return maxX!; + if (_validData.isEmpty) return 10; + return _validData.last.x; + } + + double _getMinY() { + if (minY != null) return minY!; + if (_validData.isEmpty) return 0; + final values = _validData.map((e) => e.y).toList(); + final dataMin = values.reduce((a, b) => a < b ? a : b); + final range = _getDataRange(); + final result = dataMin - (range * 0.1); + return result.isFinite ? result : 0; + } + + double _getMaxY() { + if (maxY != null) return maxY!; + if (_validData.isEmpty) return 100; + final values = _validData.map((e) => e.y).toList(); + final dataMax = values.reduce((a, b) => a > b ? a : b); + final range = _getDataRange(); + final result = dataMax + (range * 0.1); + return result.isFinite ? result : 100; + } + + double _getDataRange() { + if (_validData.isEmpty) return 1; + final values = _validData.map((e) => e.y).toList(); + final dataMin = values.reduce((a, b) => a < b ? a : b); + final dataMax = values.reduce((a, b) => a > b ? a : b); + final range = dataMax - dataMin; + if (!range.isFinite || range <= 0) { + return dataMax.abs().isFinite ? dataMax.abs() : 1; + } + return range; + } + + double _calculateGridInterval() { + final range = _getMaxY() - _getMinY(); + if (range <= 0 || !range.isFinite) return 1; + final interval = range / 5; + if (!interval.isFinite) return 1; + if (interval >= 1000) return (interval / 1000).ceilToDouble() * 1000; + if (interval >= 100) return (interval / 100).ceilToDouble() * 100; + if (interval >= 10) return (interval / 10).ceilToDouble() * 10; + if (interval >= 1) return interval.ceilToDouble(); + final result = (interval * 10).ceilToDouble() / 10; + return result.isFinite ? result : 1; + } +} diff --git a/lib/view/widgets/sensor_controls.dart b/lib/view/widgets/sensor_controls.dart new file mode 100644 index 000000000..51a763195 --- /dev/null +++ b/lib/view/widgets/sensor_controls.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../l10n/app_localizations.dart'; +import '../../providers/locator.dart'; +import '../../theme/colors.dart'; + +AppLocalizations appLocalizations = getIt.get(); + +class SensorControlsWidget extends StatefulWidget { + final bool isPlaying; + final bool isLooping; + final int timegapMs; + final int numberOfReadings; + final VoidCallback onPlayPause; + final VoidCallback onLoop; + final Function(int) onTimegapChanged; + final Function(int) onNumberOfReadingsChanged; + final VoidCallback? onClearData; + final VoidCallback? onReset; + + const SensorControlsWidget({ + super.key, + required this.isPlaying, + required this.isLooping, + required this.timegapMs, + required this.numberOfReadings, + required this.onPlayPause, + required this.onLoop, + required this.onTimegapChanged, + required this.onNumberOfReadingsChanged, + this.onClearData, + this.onReset, + }); + + @override + State createState() => _SensorControlsWidgetState(); +} + +class _SensorControlsWidgetState extends State { + late TextEditingController _numberController; + late FocusNode _textFieldFocusNode; + + @override + void initState() { + super.initState(); + _numberController = TextEditingController( + text: widget.numberOfReadings.toString(), + ); + _textFieldFocusNode = FocusNode(); + } + + @override + void didUpdateWidget(SensorControlsWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.numberOfReadings != oldWidget.numberOfReadings) { + _numberController.text = widget.numberOfReadings.toString(); + } + } + + @override + void dispose() { + _numberController.dispose(); + _textFieldFocusNode.dispose(); + super.dispose(); + } + + void _onNumberChanged(String value) { + final number = int.tryParse(value); + if (number != null && number > 0) { + widget.onNumberOfReadingsChanged(number); + } + } + + void _onTextFieldSubmitted(String value) { + _textFieldFocusNode.unfocus(); + _onNumberChanged(value); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: cardBackgroundColor, + border: Border( + top: BorderSide(color: primaryRed, width: 2), + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.zero, + topRight: Radius.zero, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(26), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + _buildPlayPauseButton(), + const SizedBox(width: 12), + Expanded( + child: _buildNumberOfReadingsField(), + ), + const SizedBox(width: 12), + _buildLoopButton(), + if (widget.onClearData != null || widget.onReset != null) ...[ + const SizedBox(width: 12), + _buildActionButtons(), + ], + ], + ), + const SizedBox(height: 16), + _buildTimegapSlider(), + ], + ), + ), + ); + } + + Widget _buildPlayPauseButton() { + return GestureDetector( + onTap: widget.onPlayPause, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: primaryRed, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: primaryRed.withAlpha(80), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon( + widget.isPlaying ? Icons.pause : Icons.play_arrow, + color: buttonTextColor, + size: 24, + ), + ), + ); + } + + Widget _buildNumberOfReadingsField() { + return Container( + height: 40, + decoration: BoxDecoration( + border: Border.all(color: sensorControlsTextBox), + borderRadius: BorderRadius.circular(8), + color: cardBackgroundColor, + ), + child: TextField( + controller: _numberController, + focusNode: _textFieldFocusNode, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(4), + ], + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: blackTextColor, + fontWeight: FontWeight.w500, + ), + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + hintText: appLocalizations.numberOfSampes, + ), + cursorColor: blackTextColor, + onChanged: _onNumberChanged, + onSubmitted: _onTextFieldSubmitted, + ), + ); + } + + Widget _buildLoopButton() { + return GestureDetector( + onTap: widget.onLoop, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: widget.isLooping + ? primaryRed.withAlpha(26) + : sensorStatusBackgroundColor, + border: Border.all( + color: widget.isLooping ? primaryRed : sensorStatusBorder, + width: 1, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.all_inclusive, + color: widget.isLooping ? primaryRed : sensorControlIconColor, + size: 18, + ), + ), + ); + } + + Widget _buildActionButtons() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.onClearData != null) + _buildActionButton( + icon: Icons.clear_all, + onTap: widget.onClearData!, + tooltip: appLocalizations.clearData, + ), + ], + ); + } + + Widget _buildActionButton({ + required IconData icon, + required VoidCallback onTap, + required String tooltip, + }) { + return Tooltip( + message: tooltip, + child: GestureDetector( + onTap: onTap, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: sensorStatusBackgroundColor, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: sensorStatusBorder), + ), + child: Icon( + icon, + color: sensorControlIconColor, + size: 18, + ), + ), + ), + ); + } + + Widget _buildTimegapSlider() { + return Row( + children: [ + Text( + appLocalizations.timeGap, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: blackTextColor, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: primaryRed, + inactiveTrackColor: sensorStatusBorder, + thumbColor: primaryRed, + overlayColor: primaryRed.withAlpha(50), + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10), + trackHeight: 2, + valueIndicatorColor: primaryRed, + valueIndicatorTextStyle: const TextStyle( + fontSize: 12, + ), + ), + child: Slider( + value: widget.timegapMs.toDouble(), + min: 200, + max: 1000, + label: '${widget.timegapMs}${appLocalizations.ms}', + onChanged: (value) => widget.onTimegapChanged(value.toInt()), + ), + ), + ), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: sensorStatusBackgroundColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${widget.timegapMs}${appLocalizations.ms}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: blackTextColor, + ), + ), + ), + ], + ); + } +}