diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 19f6ed6e7..ab532dc6c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -332,6 +332,43 @@ "sharingMessage" : "Sharing PSLab Data", "delete" : "Delete", "deleteHint": "Are you sure you want to delete this file?", + "deleteFile" : "Delete File", + "deleteAllData" : "Delete All Data", + "deleteCautionMessage" : "Are you sure you want to delete all logged data for this instrument?", + "deleteAll" : "Delete All", + "noLoggedData" : "No logged data found.", + "importLog" : "Import Log", + "failedToSave" : "Failed to save file. No data was recorded.", + "fileSaved" : "File saved", + "save" : "Save", + "enterFileName" : "Enter filename (leave empty for auto-generated name)", + "fileName" : "Filename", + "saveRecording" : "Save Recording", + "recordingStarted" : "Recording started", + "noValidData" : "No valid data to display.", + "csvPickingError" : "Error picking or reading CSV file", + "csvReadingError" : "Error reading CSV from file", + "sharingError" : "Error sharing file", + "csvGettingError" : "Error getting saved files", + "unsupportedPlatform" : "Unsupported platform", + "noDataRecorded" : "No data recorded to save for", + "csvFileSaved" : "CSV file saved at", + "csvSavingError" : "Error saving CSV file", + "csvDeletingError" : "Error deleting file", + "fileDeleted" : "File deleted", + "soundmeterConfig" : "Soundmeter Configurations", + "barometerConfig" : "Barometer Configurations", + "baroUpdatePeriodHint" : "Please provide time interval at which data will be updated (100 ms to 2000 ms)", + "barometerHighLimitHint" : "Please provide the maximum limit of lux value to be recorded (0 atm to 1.10 atm)", + "gyroscopeConfigurations" : "Gyroscope Configurations", + "gyroscopeHighLimitHint" : "Please provide the maximum limit of lux value to be recorded (0 rad/s to 1000 rad/s)", + "accelerometerConfigurations" : "Accelerometer Configurations", + "accelerometerUpdatePeriodHint" : "Please provide time interval at which data will be updated", + "accelerometerHighLimitHint" : "Please provide the maximum limit of lux value to be recorded", + "soundmeterSnackBarMessage" : "Unable to access sound sensor", + "dangerous" : "Dangerous", + "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.", "documentationLink" : "https://docs.pslab.io/", "documentationError" : "Could not open the documentation link", "deleteFile": "Delete File", @@ -391,4 +428,4 @@ "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 8cc552eb7..b2ccb018a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2086,18 +2086,6 @@ abstract class AppLocalizations { /// **'Are you sure you want to delete this file?'** String get deleteHint; - /// No description provided for @documentationLink. - /// - /// In en, this message translates to: - /// **'https://docs.pslab.io/'** - String get documentationLink; - - /// No description provided for @documentationError. - /// - /// In en, this message translates to: - /// **'Could not open the documentation link'** - String get documentationError; - /// No description provided for @deleteFile. /// /// In en, this message translates to: @@ -2296,6 +2284,18 @@ abstract class AppLocalizations { /// **'Please provide the maximum limit of lux value to be recorded'** String get accelerometerHighLimitHint; + /// No description provided for @soundmeterSnackBarMessage. + /// + /// In en, this message translates to: + /// **'Unable to access sound sensor'** + String get soundmeterSnackBarMessage; + + /// No description provided for @dangerous. + /// + /// In en, this message translates to: + /// **'Dangerous'** + String get dangerous; + /// No description provided for @roboticArmIntro. /// /// In en, this message translates to: @@ -2308,6 +2308,18 @@ abstract class AppLocalizations { /// **'• 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 @documentationLink. + /// + /// In en, this message translates to: + /// **'https://docs.pslab.io/'** + String get documentationLink; + + /// No description provided for @documentationError. + /// + /// In en, this message translates to: + /// **'Could not open the documentation link'** + String get documentationError; + /// No description provided for @autoscan. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 636ecb28b..3d5b31d41 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1067,12 +1067,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get deleteHint => 'Are you sure you want to delete this file?'; - @override - String get documentationLink => 'https://docs.pslab.io/'; - - @override - String get documentationError => 'Could not open the documentation link'; - @override String get deleteFile => 'Delete File'; @@ -1179,6 +1173,12 @@ class AppLocalizationsEn extends AppLocalizations { String get accelerometerHighLimitHint => 'Please provide the maximum limit of lux value to be recorded'; + @override + String get soundmeterSnackBarMessage => 'Unable to access sound sensor'; + + @override + String get dangerous => 'Dangerous'; + @override String get 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.'; @@ -1187,6 +1187,12 @@ class AppLocalizationsEn extends AppLocalizations { 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 documentationLink => 'https://docs.pslab.io/'; + + @override + String get documentationError => 'Could not open the documentation link'; + @override String get autoscan => 'Autoscan'; diff --git a/lib/providers/luxmeter_state_provider.dart b/lib/providers/luxmeter_state_provider.dart index c3bcff93d..5dc761c4f 100644 --- a/lib/providers/luxmeter_state_provider.dart +++ b/lib/providers/luxmeter_state_provider.dart @@ -28,7 +28,6 @@ class LuxMeterStateProvider extends ChangeNotifier { bool _sensorAvailable = false; bool _isRecording = false; List> _recordedData = []; - double _recordingStartTime = 0.0; bool get isRecording => _isRecording; LuxMeterConfigProvider? _configProvider; @@ -112,13 +111,14 @@ class LuxMeterStateProvider extends ChangeNotifier { final time = _currentTime; if (lux != null) { if (_isRecording) { - final relativeTime = time - _recordingStartTime; final now = DateTime.now(); final dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss.SSS'); _recordedData.add([ + now.millisecondsSinceEpoch.toString(), dateFormat.format(now), - relativeTime.toStringAsFixed(2), lux.toStringAsFixed(2), + 0, + 0 ]); } @@ -146,8 +146,9 @@ class LuxMeterStateProvider extends ChangeNotifier { void startRecording() { _isRecording = true; - _recordingStartTime = _currentTime; - _recordedData = []; + _recordedData = [ + ['Timestamp', 'DateTime', 'Readings', 'Latitude', 'Longitude'] + ]; notifyListeners(); } diff --git a/lib/providers/soundmeter_state_provider.dart b/lib/providers/soundmeter_state_provider.dart index 21dcdf731..475ffe58d 100644 --- a/lib/providers/soundmeter_state_provider.dart +++ b/lib/providers/soundmeter_state_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math'; import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; import 'package:pslab/l10n/app_localizations.dart'; import 'package:pslab/others/logger_service.dart'; import 'package:flutter/foundation.dart'; @@ -18,13 +19,20 @@ class SoundMeterStateProvider extends ChangeNotifier { AudioJack? _audioJack; double _startTime = 0; double _currentTime = 0; - final int _maxLength = 50; + final int _chartMaxLength = 50; double _dbMin = 0; double _dbMax = 0; double _dbSum = 0; int _dataCount = 0; + bool _isRecording = false; + List> _recordedData = []; + bool get isRecording => _isRecording; + + Function(String)? onSensorError; + + void initializeSensors({Function(String)? onError}) async { + onSensorError = onError; - void initializeSensors() async { try { _audioJack = AudioJack(); await _audioJack!.initialize(); @@ -50,9 +58,15 @@ class SoundMeterStateProvider extends ChangeNotifier { }); } catch (e) { logger.e("${appLocalizations.soundMeterInitialError} $e"); + _handleSensorError(e); } } + void _handleSensorError(dynamic error) { + onSensorError?.call(appLocalizations.soundmeterSnackBarMessage); + logger.e("${appLocalizations.soundMeterInitialError} $error"); + } + double _calculateDecibels(List audioData) { if (audioData.isEmpty) return 0.0; @@ -87,11 +101,22 @@ class SoundMeterStateProvider extends ChangeNotifier { void _updateData() { final db = _currentDb; final time = _currentTime; + if (_isRecording) { + final now = DateTime.now(); + final dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss.SSS'); + _recordedData.add([ + now.millisecondsSinceEpoch.toString(), + dateFormat.format(now), + db.toStringAsFixed(2), + 0, + 0 + ]); + } _dbData.add(db); _timeData.add(time); _dbSum += db; _dataCount++; - if (_dbData.length > _maxLength) { + if (_dbData.length > _chartMaxLength) { final removedValue = _dbData.removeAt(0); _timeData.removeAt(0); _dbSum -= removedValue; @@ -108,6 +133,20 @@ class SoundMeterStateProvider extends ChangeNotifier { notifyListeners(); } + void startRecording() { + _isRecording = true; + _recordedData = [ + ['Timestamp', 'DateTime', 'Readings', 'Latitude', 'Longitude'] + ]; + notifyListeners(); + } + + List> stopRecording() { + _isRecording = false; + notifyListeners(); + return _recordedData; + } + double getCurrentDb() => _currentDb; double getMinDb() => _dbMin; double getMaxDb() => _dbMax; diff --git a/lib/view/logged_data_chart_screen.dart b/lib/view/logged_data_chart_screen.dart index 01fa0a56a..a84cb6252 100644 --- a/lib/view/logged_data_chart_screen.dart +++ b/lib/view/logged_data_chart_screen.dart @@ -20,7 +20,7 @@ class LoggedDataChartScreen extends StatefulWidget { required this.fileName, this.xAxisLabel = 'Time (s)', this.yAxisLabel = 'Value', - this.xDataColumnIndex = 1, + this.xDataColumnIndex = 0, this.yDataColumnIndex = 2, }); @@ -105,18 +105,7 @@ class _LoggedDataChartScreenState extends State { sideTitles: SideTitles( showTitles: true, reservedSize: reservedSizeBottom, - getTitlesWidget: (value, meta) { - return SideTitleWidget( - meta: meta, - child: Text( - value.toStringAsFixed(1), - style: TextStyle( - color: blackTextColor, - fontSize: chartFontSize, - ), - ), - ); - }, + getTitlesWidget: _sideTitleWidgets, interval: timeInterval, ), ), @@ -195,6 +184,7 @@ class _LoggedDataChartScreenState extends State { double maxY = 0; double maxX = 0; double minX = 0; + double? startTime; for (int i = 1; i < widget.data.length; i++) { final row = widget.data[i]; @@ -204,10 +194,16 @@ class _LoggedDataChartScreenState extends State { final yValue = _parseDouble(row[widget.yDataColumnIndex]); if (xValue != null && yValue != null) { - spots.add(FlSpot(xValue, yValue)); + if (startTime == null) { + startTime = xValue; + minX = 0; + } + + final relativeTime = ((xValue - startTime) / 1000.0); + + spots.add(FlSpot(relativeTime, yValue)); if (yValue > maxY) maxY = yValue; - if (xValue > maxX) maxX = xValue; - if (spots.length == 1 || xValue < minX) minX = xValue; + if (relativeTime > maxX) maxX = relativeTime; } } } @@ -252,4 +248,39 @@ class _LoggedDataChartScreenState extends State { ), ); } + + 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: blackTextColor, + 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, + ), + ); + } } diff --git a/lib/view/logged_data_screen.dart b/lib/view/logged_data_screen.dart index 3318b8aa1..00256f285 100644 --- a/lib/view/logged_data_screen.dart +++ b/lib/view/logged_data_screen.dart @@ -112,14 +112,14 @@ class _LoggedDataScreenState extends State { return { 'xAxisLabel': appLocalizations.timeAxisLabel, 'yAxisLabel': appLocalizations.lx, - 'xDataColumnIndex': 1, + 'xDataColumnIndex': 0, 'yDataColumnIndex': 2, }; case 'soundmeter': return { 'xAxisLabel': appLocalizations.timeAxisLabel, 'yAxisLabel': appLocalizations.db, - 'xDataColumnIndex': 1, + 'xDataColumnIndex': 0, 'yDataColumnIndex': 2, }; case 'barometer': diff --git a/lib/view/soundmeter_screen.dart b/lib/view/soundmeter_screen.dart index 570df2c1e..7574ba53d 100644 --- a/lib/view/soundmeter_screen.dart +++ b/lib/view/soundmeter_screen.dart @@ -8,7 +8,10 @@ import 'package:pslab/view/widgets/common_scaffold_widget.dart'; import 'package:pslab/view/widgets/guide_widget.dart'; import 'package:pslab/view/widgets/soundmeter_card.dart'; import 'package:fl_chart/fl_chart.dart'; +import 'package:pslab/others/csv_service.dart'; +import 'package:pslab/view/logged_data_screen.dart'; import '../providers/soundmeter_config_provider.dart'; +import '../constants.dart'; import '../theme/colors.dart'; class SoundMeterScreen extends StatefulWidget { @@ -19,8 +22,11 @@ class SoundMeterScreen extends StatefulWidget { class _SoundMeterScreenState extends State { AppLocalizations appLocalizations = getIt.get(); + final CsvService _csvService = CsvService(); + late SoundMeterStateProvider _provider; bool _showGuide = false; static const imagePath = 'assets/images/bh1750_schematic.png'; + void _showInstrumentGuide() { setState(() { _showGuide = true; @@ -71,7 +77,7 @@ class _SoundMeterScreenState extends State { if (value != null) { switch (value) { case 'show_logged_data': - // TODO + _navigateToLoggedData(); break; case 'sound_meter_config': _navigateToConfig(); @@ -93,54 +99,182 @@ class _SoundMeterScreenState extends State { ); } + Future _navigateToLoggedData() async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LoggedDataScreen( + instrumentName: 'soundmeter', + appBarName: 'Sound Meter', + instrumentIcon: instrumentIcons[15], + ), + ), + ); + } + + Future _toggleRecording() async { + if (_provider.isRecording) { + final data = _provider.stopRecording(); + await _showSaveFileDialog(data); + } else { + _provider.startRecording(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${appLocalizations.recordingStarted}...', + style: TextStyle(color: snackBarContentColor), + ), + backgroundColor: snackBarBackgroundColor, + ), + ); + } + } + + Future _showSaveFileDialog(List> data) async { + final TextEditingController filenameController = TextEditingController(); + final String defaultFilename = ''; + filenameController.text = defaultFilename; + + final String? fileName = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(appLocalizations.saveRecording), + content: TextField( + controller: filenameController, + decoration: InputDecoration( + hintText: appLocalizations.enterFileName, + labelText: appLocalizations.fileName, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(appLocalizations.cancel), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context, filenameController.text); + }, + child: Text(appLocalizations.save), + ), + ], + ); + }, + ); + + if (fileName != null) { + _csvService.writeMetaData('soundmeter', data); + final file = await _csvService.saveCsvFile('soundmeter', fileName, data); + if (mounted) { + if (file != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${appLocalizations.fileSaved}: ${file.path.split('/').last}', + style: TextStyle(color: snackBarContentColor), + ), + backgroundColor: snackBarBackgroundColor, + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + appLocalizations.failedToSave, + style: TextStyle(color: snackBarContentColor), + ), + backgroundColor: snackBarBackgroundColor, + ), + ); + } + } + } + } + @override - Widget build(BuildContext context) { - return MultiProvider( - providers: [ - ChangeNotifierProvider( - create: (_) => SoundMeterStateProvider()..initializeSensors(), + void initState() { + super.initState(); + _provider = SoundMeterStateProvider(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _provider.initializeSensors(onError: _showSensorErrorSnackbar); + } + }); + } + + @override + void dispose() { + _provider.dispose(); + super.dispose(); + } + + 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 ChangeNotifierProvider.value( + value: _provider, child: Stack( children: [ - CommonScaffold( - title: appLocalizations.soundMeterTitle, - onGuidePressed: _showInstrumentGuide, - onOptionsPressed: _showOptionsMenu, - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - final isLargeScreen = constraints.maxWidth > 900; - if (isLargeScreen) { - return Row( - children: [ - const Expanded( - flex: 35, - child: SoundMeterCard(), - ), - Expanded( - flex: 65, - child: _buildChartSection(), - ), - ], - ); - } else { - return Column( - children: [ - const Expanded( - flex: 45, - child: SoundMeterCard(), - ), - Expanded( - flex: 55, - child: _buildChartSection(), - ), - ], - ); - } - }, - ), - ), + Consumer( + builder: (context, provider, child) { + return CommonScaffold( + title: appLocalizations.soundMeterTitle, + onGuidePressed: _showInstrumentGuide, + onOptionsPressed: _showOptionsMenu, + onRecordPressed: _toggleRecording, + isRecording: provider.isRecording, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final isLargeScreen = constraints.maxWidth > 900; + if (isLargeScreen) { + return Row( + children: [ + const Expanded( + flex: 35, + child: SoundMeterCard(), + ), + Expanded( + flex: 65, + child: _buildChartSection(), + ), + ], + ); + } else { + return Column( + children: [ + const Expanded( + flex: 45, + child: SoundMeterCard(), + ), + Expanded( + flex: 55, + child: _buildChartSection(), + ), + ], + ); + } + }, + ), + ), + ); + }, ), if (_showGuide) InstrumentOverviewDrawer( @@ -318,7 +452,7 @@ class _SoundMeterScreenState extends State { color: soundMeterSafeLimitColor, fontSize: 12, ), - labelResolver: (line) => '"Dangerous"', + labelResolver: (line) => appLocalizations.dangerous, ), ), ],