diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b919bb915..e01bfbba7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,11 +1,18 @@ - - + + + + + + android:roundIcon="@drawable/launcher_icon_round" + android:requestLegacyExternalStorage="true" + tools:ignore="ScopedStorage"> + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index dca52e035..3fd130c58 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -47,5 +47,9 @@ UIApplicationSupportsIndirectInputEvents + LSSupportsOpeningDocumentsInPlace + + UIFileSharingEnabled + diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c24ec2904..3f2278495 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -306,6 +306,33 @@ "baroMeterBulletPoint2": "If you want to use the sensor BMP-180, connect the sensor to PSLab device as shown in the figure.", "baroMeterBulletPoint3": "The above pin configuration has to be same except for the pin GND. GND is meant for Ground and any of the PSLab device GND pins can be used since they are common.", "baroMeterBulletPoint4": "Select the sensor by going to the Configure tab from the bottom navigation bar and choose BMP-180 in the drop down menu under Select Sensor.", + "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)", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c2a181ac0..1f83d13e8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1930,6 +1930,168 @@ abstract class AppLocalizations { /// **'Select the sensor by going to the Configure tab from the bottom navigation bar and choose BMP-180 in the drop down menu under Select Sensor.'** String get baroMeterBulletPoint4; + /// No description provided for @sharingMessage. + /// + /// In en, this message translates to: + /// **'Sharing PSLab Data'** + String get sharingMessage; + + /// No description provided for @delete. + /// + /// In en, this message translates to: + /// **'Delete'** + String get delete; + + /// No description provided for @deleteHint. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete this file?'** + String get deleteHint; + + /// No description provided for @deleteFile. + /// + /// In en, this message translates to: + /// **'Delete File'** + String get deleteFile; + + /// No description provided for @deleteAllData. + /// + /// In en, this message translates to: + /// **'Delete All Data'** + String get deleteAllData; + + /// No description provided for @deleteCautionMessage. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete all logged data for this instrument?'** + String get deleteCautionMessage; + + /// No description provided for @deleteAll. + /// + /// In en, this message translates to: + /// **'Delete All'** + String get deleteAll; + + /// No description provided for @noLoggedData. + /// + /// In en, this message translates to: + /// **'No logged data found.'** + String get noLoggedData; + + /// No description provided for @importLog. + /// + /// In en, this message translates to: + /// **'Import Log'** + String get importLog; + + /// No description provided for @failedToSave. + /// + /// In en, this message translates to: + /// **'Failed to save file. No data was recorded.'** + String get failedToSave; + + /// No description provided for @fileSaved. + /// + /// In en, this message translates to: + /// **'File saved'** + String get fileSaved; + + /// No description provided for @save. + /// + /// In en, this message translates to: + /// **'Save'** + String get save; + + /// No description provided for @enterFileName. + /// + /// In en, this message translates to: + /// **'Enter filename (leave empty for auto-generated name)'** + String get enterFileName; + + /// No description provided for @fileName. + /// + /// In en, this message translates to: + /// **'Filename'** + String get fileName; + + /// No description provided for @saveRecording. + /// + /// In en, this message translates to: + /// **'Save Recording'** + String get saveRecording; + + /// No description provided for @recordingStarted. + /// + /// In en, this message translates to: + /// **'Recording started'** + String get recordingStarted; + + /// No description provided for @noValidData. + /// + /// In en, this message translates to: + /// **'No valid data to display.'** + String get noValidData; + + /// No description provided for @csvPickingError. + /// + /// In en, this message translates to: + /// **'Error picking or reading CSV file'** + String get csvPickingError; + + /// No description provided for @csvReadingError. + /// + /// In en, this message translates to: + /// **'Error reading CSV from file'** + String get csvReadingError; + + /// No description provided for @sharingError. + /// + /// In en, this message translates to: + /// **'Error sharing file'** + String get sharingError; + + /// No description provided for @csvGettingError. + /// + /// In en, this message translates to: + /// **'Error getting saved files'** + String get csvGettingError; + + /// No description provided for @unsupportedPlatform. + /// + /// In en, this message translates to: + /// **'Unsupported platform'** + String get unsupportedPlatform; + + /// No description provided for @noDataRecorded. + /// + /// In en, this message translates to: + /// **'No data recorded to save for'** + String get noDataRecorded; + + /// No description provided for @csvFileSaved. + /// + /// In en, this message translates to: + /// **'CSV file saved at'** + String get csvFileSaved; + + /// No description provided for @csvSavingError. + /// + /// In en, this message translates to: + /// **'Error saving CSV file'** + String get csvSavingError; + + /// No description provided for @csvDeletingError. + /// + /// In en, this message translates to: + /// **'Error deleting file'** + String get csvDeletingError; + + /// No description provided for @fileDeleted. + /// + /// In en, this message translates to: + /// **'File deleted'** + String get fileDeleted; + /// No description provided for @soundmeterConfig. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e92a05fe6..04d10402b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -987,6 +987,88 @@ class AppLocalizationsEn extends AppLocalizations { 'Select the sensor by going to the Configure tab from the bottom navigation bar and choose BMP-180 in the drop down menu under Select Sensor.'; @override + String get sharingMessage => 'Sharing PSLab Data'; + + @override + String get delete => 'Delete'; + + @override + String get deleteHint => 'Are you sure you want to delete this file?'; + + @override + String get deleteFile => 'Delete File'; + + @override + String get deleteAllData => 'Delete All Data'; + + @override + String get deleteCautionMessage => + 'Are you sure you want to delete all logged data for this instrument?'; + + @override + String get deleteAll => 'Delete All'; + + @override + String get noLoggedData => 'No logged data found.'; + + @override + String get importLog => 'Import Log'; + + @override + String get failedToSave => 'Failed to save file. No data was recorded.'; + + @override + String get fileSaved => 'File saved'; + + @override + String get save => 'Save'; + + @override + String get enterFileName => + 'Enter filename (leave empty for auto-generated name)'; + + @override + String get fileName => 'Filename'; + + @override + String get saveRecording => 'Save Recording'; + + @override + String get recordingStarted => 'Recording started'; + + @override + String get noValidData => 'No valid data to display.'; + + @override + String get csvPickingError => 'Error picking or reading CSV file'; + + @override + String get csvReadingError => 'Error reading CSV from file'; + + @override + String get sharingError => 'Error sharing file'; + + @override + String get csvGettingError => 'Error getting saved files'; + + @override + String get unsupportedPlatform => 'Unsupported platform'; + + @override + String get noDataRecorded => 'No data recorded to save for'; + + @override + String get csvFileSaved => 'CSV file saved at'; + + @override + String get csvSavingError => 'Error saving CSV file'; + + @override + String get csvDeletingError => 'Error deleting file'; + + @override + String get fileDeleted => 'File deleted'; + String get soundmeterConfig => 'Soundmeter Configurations'; @override diff --git a/lib/others/csv_service.dart b/lib/others/csv_service.dart new file mode 100644 index 000000000..91d732dc2 --- /dev/null +++ b/lib/others/csv_service.dart @@ -0,0 +1,175 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:csv/csv.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:pslab/others/logger_service.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:intl/intl.dart'; + +import '../l10n/app_localizations.dart'; +import '../providers/locator.dart'; + +class CsvService { + AppLocalizations appLocalizations = getIt.get(); + + Future getInstrumentDirectory(String instrumentName) async { + if (Platform.isAndroid) { + await requestStoragePermission(); + final directory = + Directory('/storage/emulated/0/Android/media/PSLab/$instrumentName'); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + return directory; + } else if (Platform.isIOS || + Platform.isWindows || + Platform.isMacOS || + Platform.isLinux) { + final dir = await getApplicationDocumentsDirectory(); + final directory = Directory('${dir.path}/PSLab/$instrumentName'); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + return directory; + } else { + throw UnsupportedError(appLocalizations.unsupportedPlatform); + } + } + + Future requestStoragePermission() async { + if (Platform.isAndroid) { + final status = await Permission.manageExternalStorage.request(); + if (!status.isGranted) { + await openAppSettings(); + } + } + } + + Future saveCsvFile( + String instrumentName, String fileName, List> data) async { + try { + if (data.length <= 1) { + logger.w('${appLocalizations.noDataRecorded} $fileName'); + return null; + } + final directory = await getInstrumentDirectory(instrumentName); + + String finalFileName; + if (fileName.isEmpty) { + finalFileName = + '${DateFormat('yyyy-MM-dd_HH-mm-ss').format(DateTime.now())}.csv'; + } else { + finalFileName = fileName.endsWith('.csv') ? fileName : '$fileName.csv'; + } + + final file = File('${directory.path}/$finalFileName'); + + String csvData = const ListToCsvConverter().convert(data); + await file.writeAsString(csvData); + logger.i('${appLocalizations.csvFileSaved}: ${file.path}'); + return file; + } catch (e) { + logger.e('${appLocalizations.csvSavingError}: $e'); + return null; + } + } + + Future> getSavedFiles(String instrumentName) async { + try { + final directory = await getInstrumentDirectory(instrumentName); + final files = directory + .listSync() + .where((item) => item.path.endsWith('.csv')) + .toList(); + files.sort( + (a, b) => b.statSync().modified.compareTo(a.statSync().modified)); + return files; + } catch (e) { + logger.e('${appLocalizations.csvGettingError}: $e'); + return []; + } + } + + Future deleteFile(String filePath) async { + try { + final file = File(filePath); + if (await file.exists()) { + await file.delete(); + logger.i('${appLocalizations.fileDeleted}: $filePath'); + } + } catch (e) { + logger.e('${appLocalizations.csvDeletingError}: $e'); + } + } + + Future deleteAllFiles(String instrumentName) async { + try { + final directory = await getInstrumentDirectory(instrumentName); + if (await directory.exists()) { + await directory.delete(recursive: true); + logger.i('All files for $instrumentName deleted.'); + } + } catch (e) { + logger.e('Error deleting all files for $instrumentName: $e'); + } + } + + Future shareFile(String filePath) async { + try { + final xFile = XFile(filePath); + await SharePlus.instance.share( + ShareParams(files: [xFile], text: appLocalizations.sharingMessage)); + } catch (e) { + logger.e('${appLocalizations.sharingError}: $e'); + } + } + + Future>?> pickAndReadCsvFile() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['csv'], + ); + + if (result != null && result.files.single.path != null) { + final file = File(result.files.single.path!); + return await readCsvFromFile(file); + } + } catch (e) { + logger.e('${appLocalizations.csvPickingError}: $e'); + } + return null; + } + + Future>> readCsvFromFile(File file) async { + try { + final input = file.openRead(); + final fields = await input + .transform(utf8.decoder) + .transform(const CsvToListConverter(shouldParseNumbers: true)) + .toList(); + return fields; + } catch (e) { + logger.e('${appLocalizations.csvReadingError}: $e'); + return []; + } + } + + void writeMetaData(String instrumentName, List> data) { + if (data.isNotEmpty && data[0].isNotEmpty && data[0][0] == instrumentName) { + return; + } + + final now = DateTime.now(); + final sdf = DateFormat('yyyy-MM-dd HH:mm:ss'); + final metaDataTime = sdf.format(now); + final metaData = [ + instrumentName, + metaDataTime.split(' ')[0], + metaDataTime.split(' ')[1] + ]; + data.insert(0, metaData); + } +} diff --git a/lib/providers/luxmeter_state_provider.dart b/lib/providers/luxmeter_state_provider.dart index 415559964..c3bcff93d 100644 --- a/lib/providers/luxmeter_state_provider.dart +++ b/lib/providers/luxmeter_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:light/light.dart'; @@ -19,12 +20,16 @@ class LuxMeterStateProvider extends ChangeNotifier { Light? _light; double _startTime = 0; double _currentTime = 0; - final int _maxLength = 50; + final int _chartMaxLength = 50; double _luxMin = 0; double _luxMax = 0; double _luxSum = 0; int _dataCount = 0; bool _sensorAvailable = false; + bool _isRecording = false; + List> _recordedData = []; + double _recordingStartTime = 0.0; + bool get isRecording => _isRecording; LuxMeterConfigProvider? _configProvider; @@ -103,15 +108,26 @@ class LuxMeterStateProvider extends ChangeNotifier { } void _updateData() { - if (!_sensorAvailable) return; - - final lux = _currentLux; + final lux = _sensorAvailable ? _currentLux : null; final time = _currentTime; - _luxData.add(lux); - _timeData.add(time); - _luxSum += lux; - _dataCount++; - if (_luxData.length > _maxLength) { + 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([ + dateFormat.format(now), + relativeTime.toStringAsFixed(2), + lux.toStringAsFixed(2), + ]); + } + + _luxData.add(lux); + _timeData.add(time); + _luxSum += lux; + _dataCount++; + } + if (_luxData.length > _chartMaxLength) { final removedValue = _luxData.removeAt(0); _timeData.removeAt(0); _luxSum -= removedValue; @@ -128,6 +144,19 @@ class LuxMeterStateProvider extends ChangeNotifier { notifyListeners(); } + void startRecording() { + _isRecording = true; + _recordingStartTime = _currentTime; + _recordedData = []; + notifyListeners(); + } + + List> stopRecording() { + _isRecording = false; + notifyListeners(); + return _recordedData; + } + double getCurrentLux() => _currentLux; double getMinLux() => _luxMin; double getMaxLux() => _luxMax; diff --git a/lib/view/logged_data_chart_screen.dart b/lib/view/logged_data_chart_screen.dart new file mode 100644 index 000000000..01fa0a56a --- /dev/null +++ b/lib/view/logged_data_chart_screen.dart @@ -0,0 +1,255 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pslab/theme/colors.dart'; + +import '../l10n/app_localizations.dart'; +import '../providers/locator.dart'; + +class LoggedDataChartScreen extends StatefulWidget { + final List> data; + final String fileName; + final String xAxisLabel; + final String yAxisLabel; + final int xDataColumnIndex; + final int yDataColumnIndex; + + const LoggedDataChartScreen({ + super.key, + required this.data, + required this.fileName, + this.xAxisLabel = 'Time (s)', + this.yAxisLabel = 'Value', + this.xDataColumnIndex = 1, + this.yDataColumnIndex = 2, + }); + + @override + State createState() => _LoggedDataChartScreenState(); +} + +class _LoggedDataChartScreenState extends State { + AppLocalizations appLocalizations = getIt.get(); + + @override + void initState() { + super.initState(); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeRight, + DeviceOrientation.landscapeLeft, + ]); + } + + @override + void dispose() { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + super.dispose(); + } + + double _getSafeInterval(double maxValue, {int divisions = 5}) { + if (maxValue <= 0) return 1.0; + final double interval = (maxValue / divisions).ceilToDouble(); + return interval > 0 ? interval : 1.0; + } + + double? _parseDouble(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + if (value is String) { + try { + return double.parse(value); + } catch (e) { + return null; + } + } + return null; + } + + Widget _buildChart(double screenWidth, double maxY, double maxX, double minX, + 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 ? 27.0 : 30.0; + final reservedSizeRight = screenWidth < 400 ? 27.0 : 30.0; + + return Padding( + padding: const EdgeInsets.only(right: 20.0), + child: LineChart( + LineChartData( + backgroundColor: chartBackgroundColor, + titlesData: FlTitlesData( + show: true, + topTitles: AxisTitles( + axisNameWidget: Padding( + padding: EdgeInsets.only(left: screenWidth < 400 ? 15 : 25), + child: Text( + widget.xAxisLabel, + style: TextStyle( + fontSize: axisNameFontSize, + color: blackTextColor, + fontWeight: FontWeight.bold, + ), + ), + ), + axisNameSize: screenWidth < 400 ? 18 : 20, + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: reservedSizeBottom, + getTitlesWidget: (value, meta) { + return SideTitleWidget( + meta: meta, + child: Text( + value.toStringAsFixed(1), + style: TextStyle( + color: blackTextColor, + fontSize: chartFontSize, + ), + ), + ); + }, + interval: timeInterval, + ), + ), + leftTitles: AxisTitles( + axisNameWidget: Text( + widget.yAxisLabel, + style: TextStyle( + fontSize: axisNameFontSize, + color: blackTextColor, + fontWeight: FontWeight.bold, + ), + ), + sideTitles: SideTitles( + reservedSize: reservedSizeLeft, + showTitles: true, + getTitlesWidget: (value, meta) { + return SideTitleWidget( + meta: meta, + child: Text( + value.toInt().toString(), + style: TextStyle( + color: blackTextColor, + fontSize: chartFontSize, + ), + ), + ); + }, + interval: maxY > 0 ? (maxY / 5).ceilToDouble() : 10, + ), + ), + rightTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: false, reservedSize: reservedSizeRight), + ), + ), + gridData: FlGridData( + show: true, + drawHorizontalLine: true, + drawVerticalLine: true, + horizontalInterval: maxY > 0 ? (maxY / 5).ceilToDouble() : 10, + 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: maxY > 0 ? (maxY * 1.1) : 100, + maxX: maxX > 0 ? maxX : 10, + minX: minX, + clipData: const FlClipData.all(), + lineBarsData: [ + LineChartBarData( + spots: spots, + isCurved: true, + color: chartLineColor, + barWidth: screenWidth < 400 ? 1.5 : 2.0, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final List spots = []; + double maxY = 0; + double maxX = 0; + double minX = 0; + + for (int i = 1; i < widget.data.length; i++) { + final row = widget.data[i]; + if (row.length > widget.xDataColumnIndex && + row.length > widget.yDataColumnIndex) { + final xValue = _parseDouble(row[widget.xDataColumnIndex]); + final yValue = _parseDouble(row[widget.yDataColumnIndex]); + + if (xValue != null && yValue != null) { + spots.add(FlSpot(xValue, yValue)); + if (yValue > maxY) maxY = yValue; + if (xValue > maxX) maxX = xValue; + if (spots.length == 1 || xValue < minX) minX = xValue; + } + } + } + + final screenWidth = MediaQuery.of(context).size.width; + final timeInterval = _getSafeInterval(maxX, divisions: 10); + + return Scaffold( + backgroundColor: scaffoldBackgroundColor, + appBar: AppBar( + title: Text( + widget.fileName, + style: TextStyle(color: appBarContentColor, fontSize: 15), + ), + backgroundColor: primaryRed, + iconTheme: IconThemeData(color: appBarContentColor), + ), + body: SafeArea( + child: spots.isEmpty + ? Center(child: Text(appLocalizations.noValidData)) + : Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: InteractiveViewer( + constrained: false, + scaleEnabled: false, + panEnabled: true, + child: SizedBox( + width: spots.length * 12.0 < screenWidth + ? screenWidth + : spots.length * 12.0, + height: MediaQuery.of(context).size.height - + kToolbarHeight - + MediaQuery.of(context).padding.top - + MediaQuery.of(context).padding.bottom - + 48, + child: _buildChart( + screenWidth, maxY, maxX, minX, timeInterval, spots), + ), + ), + ), + ), + ); + } +} diff --git a/lib/view/logged_data_screen.dart b/lib/view/logged_data_screen.dart new file mode 100644 index 000000000..bbee6db8d --- /dev/null +++ b/lib/view/logged_data_screen.dart @@ -0,0 +1,299 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:pslab/others/csv_service.dart'; +import 'package:pslab/theme/colors.dart'; +import 'package:pslab/view/logged_data_chart_screen.dart'; + +import '../l10n/app_localizations.dart'; +import '../providers/locator.dart'; + +class LoggedDataScreen extends StatefulWidget { + final String instrumentName; + final String appBarName; + final String instrumentIcon; + + const LoggedDataScreen( + {super.key, + required this.instrumentName, + required this.appBarName, + required this.instrumentIcon}); + + @override + State createState() => _LoggedDataScreenState(); +} + +class _LoggedDataScreenState extends State { + AppLocalizations appLocalizations = getIt.get(); + final CsvService _csvService = CsvService(); + List _files = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadFiles(); + } + + Future _loadFiles() async { + setState(() { + _isLoading = true; + }); + final files = await _csvService.getSavedFiles(widget.instrumentName); + if (mounted) { + setState(() { + _files = files; + _isLoading = false; + }); + } + } + + Future _deleteFile(String filePath, {bool askConfirm = true}) async { + bool confirmed = true; + if (askConfirm) { + confirmed = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(appLocalizations.deleteFile), + content: Text(appLocalizations.deleteHint), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(appLocalizations.cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(appLocalizations.delete), + ), + ], + ); + }, + ) ?? + false; + } + + if (confirmed) { + await _csvService.deleteFile(filePath); + _loadFiles(); + } + } + + Future _deleteAllFiles() async { + final confirmed = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(appLocalizations.deleteAllData), + content: Text(appLocalizations.deleteCautionMessage), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(appLocalizations.cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(appLocalizations.deleteAll), + ), + ], + ); + }, + ); + + if (confirmed == true) { + await _csvService.deleteAllFiles(widget.instrumentName); + _loadFiles(); + } + } + + Map _getChartConfig() { + switch (widget.instrumentName.toLowerCase()) { + case 'luxmeter': + return { + 'xAxisLabel': appLocalizations.timeAxisLabel, + 'yAxisLabel': appLocalizations.lx, + 'xDataColumnIndex': 1, + 'yDataColumnIndex': 2, + }; + case 'soundmeter': + return { + 'xAxisLabel': appLocalizations.timeAxisLabel, + 'yAxisLabel': appLocalizations.db, + 'xDataColumnIndex': 1, + 'yDataColumnIndex': 2, + }; + case 'barometer': + return { + 'xAxisLabel': appLocalizations.timeAxisLabel, + 'yAxisLabel': appLocalizations.atm, + 'xDataColumnIndex': 1, + 'yDataColumnIndex': 2, + }; + default: + return { + 'xAxisLabel': appLocalizations.timeAxisLabel, + 'yAxisLabel': 'Value', + 'xDataColumnIndex': 1, + 'yDataColumnIndex': 2, + }; + } + } + + Future _openFile(File file) async { + final data = await _csvService.readCsvFromFile(file); + if (mounted) { + final config = _getChartConfig(); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LoggedDataChartScreen( + data: data, + fileName: file.path.split('/').last, + xAxisLabel: config['xAxisLabel'], + yAxisLabel: config['yAxisLabel'], + xDataColumnIndex: config['xDataColumnIndex'], + yDataColumnIndex: config['yDataColumnIndex'], + ), + ), + ); + } + } + + Future _pickAndImportFile() async { + final data = await _csvService.pickAndReadCsvFile(); + if (data != null && mounted) { + final config = _getChartConfig(); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LoggedDataChartScreen( + data: data, + fileName: 'Imported Log', + xAxisLabel: config['xAxisLabel'], + yAxisLabel: config['yAxisLabel'], + xDataColumnIndex: config['xDataColumnIndex'], + yDataColumnIndex: config['yDataColumnIndex'], + ), + ), + ); + } + } + + void _showOptionsMenu() { + showMenu( + context: context, + position: RelativeRect.fromLTRB( + MediaQuery.of(context).size.width, + kToolbarHeight, + 0, + 0, + ), + items: [ + PopupMenuItem( + value: 'import_log', + child: Text(appLocalizations.importLog), + ), + PopupMenuItem( + value: 'delete_all', + child: Text(appLocalizations.deleteAllData), + ), + ], + ).then((value) { + if (value != null) { + switch (value) { + case 'import_log': + _pickAndImportFile(); + break; + case 'delete_all': + _deleteAllFiles(); + break; + } + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + widget.appBarName, + style: TextStyle( + color: appBarContentColor, + fontSize: 15, + ), + ), + backgroundColor: primaryRed, + iconTheme: IconThemeData(color: appBarContentColor), + actions: [ + IconButton( + icon: const Icon(Icons.more_vert), + onPressed: _showOptionsMenu, + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _files.isEmpty + ? Center( + child: Text( + appLocalizations.noLoggedData, + style: TextStyle(color: Theme.of(context).primaryColor), + ), + ) + : RefreshIndicator( + onRefresh: _loadFiles, + child: ListView.builder( + itemCount: _files.length, + itemBuilder: (context, index) { + final file = _files[index] as File; + final stat = file.statSync(); + final fileName = file.path.split('/').last; + final formattedDate = + DateFormat.yMMMd().add_jm().format(stat.modified); + + return Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + ), + color: Theme.of(context).colorScheme.surface, + margin: + const EdgeInsets.only(left: 8, right: 8, top: 8), + child: ListTile( + onTap: () => _openFile(file), + leading: CircleAvatar( + backgroundColor: Colors.transparent, + child: Image.asset( + widget.instrumentIcon, + color: primaryRed, + ), + ), + title: Text(fileName, + style: + const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text( + '${(stat.size / 1024).toStringAsFixed(2)} KB\n$formattedDate'), + isThreeLine: true, + trailing: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon(Icons.share, color: primaryRed), + onPressed: () => + _csvService.shareFile(file.path), + ), + IconButton( + icon: Icon(Icons.delete, color: primaryRed), + onPressed: () => _deleteFile(file.path), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/view/luxmeter_screen.dart b/lib/view/luxmeter_screen.dart index 3fc8608d2..0998794d3 100644 --- a/lib/view/luxmeter_screen.dart +++ b/lib/view/luxmeter_screen.dart @@ -4,12 +4,15 @@ import 'package:pslab/l10n/app_localizations.dart'; import 'package:pslab/providers/locator.dart'; import 'package:pslab/providers/luxmeter_state_provider.dart'; import 'package:pslab/providers/luxmeter_config_provider.dart'; +import 'package:pslab/others/csv_service.dart'; +import 'package:pslab/view/logged_data_screen.dart'; import 'package:pslab/view/widgets/common_scaffold_widget.dart'; import 'package:pslab/view/widgets/guide_widget.dart'; import 'package:pslab/view/widgets/luxmeter_card.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:pslab/view/luxmeter_config_screen.dart'; +import '../constants.dart'; import '../theme/colors.dart'; class LuxMeterScreen extends StatefulWidget { @@ -21,6 +24,7 @@ class LuxMeterScreen extends StatefulWidget { class _LuxMeterScreenState extends State { late LuxMeterStateProvider _provider; late LuxMeterConfigProvider _configProvider; + final CsvService _csvService = CsvService(); bool _showGuide = false; static const imagePath = 'assets/images/bh1750_schematic.png'; AppLocalizations appLocalizations = getIt.get(); @@ -78,7 +82,7 @@ class _LuxMeterScreenState extends State { if (value != null) { switch (value) { case 'show_logged_data': - // TODO + _navigateToLoggedData(); break; case 'lux_meter_config': _navigateToConfig(); @@ -101,6 +105,99 @@ class _LuxMeterScreenState extends State { ); } + Future _navigateToLoggedData() async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LoggedDataScreen( + instrumentName: 'luxmeter', + appBarName: 'Lux Meter', + instrumentIcon: instrumentIcons[6], + ), + ), + ); + } + + 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('luxmeter', data); + final file = await _csvService.saveCsvFile('luxmeter', 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 void initState() { super.initState(); @@ -147,40 +244,47 @@ class _LuxMeterScreenState extends State { value: _configProvider), ], child: Stack(children: [ - CommonScaffold( - title: appLocalizations.luxMeterTitle, - onOptionsPressed: _showOptionsMenu, - onGuidePressed: _showInstrumentGuide, - body: SafeArea(child: LayoutBuilder(builder: (context, constraints) { - final isLargeScreen = constraints.maxWidth > 900; - if (isLargeScreen) { - return Row( - children: [ - const Expanded( - flex: 35, - child: LuxMeterCard(), - ), - Expanded( - flex: 65, - child: _buildChartSection(), - ), - ], - ); - } else { - return Column( - children: [ - const Expanded( - flex: 45, - child: LuxMeterCard(), - ), - Expanded( - flex: 55, - child: _buildChartSection(), - ), - ], - ); - } - })), + Consumer( + builder: (context, provider, child) { + return CommonScaffold( + title: appLocalizations.luxMeterTitle, + onOptionsPressed: _showOptionsMenu, + onGuidePressed: _showInstrumentGuide, + 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: LuxMeterCard(), + ), + Expanded( + flex: 65, + child: _buildChartSection(), + ), + ], + ); + } else { + return Column( + children: [ + const Expanded( + flex: 45, + child: LuxMeterCard(), + ), + Expanded( + flex: 55, + child: _buildChartSection(), + ), + ], + ); + } + })), + ); + }, ), if (_showGuide) InstrumentOverviewDrawer( diff --git a/lib/view/widgets/common_scaffold_widget.dart b/lib/view/widgets/common_scaffold_widget.dart index a5181941c..cfb703b16 100644 --- a/lib/view/widgets/common_scaffold_widget.dart +++ b/lib/view/widgets/common_scaffold_widget.dart @@ -10,14 +10,21 @@ class CommonScaffold extends StatefulWidget { final List? actions; final VoidCallback? onGuidePressed; final VoidCallback? onOptionsPressed; - const CommonScaffold( - {super.key, - required this.body, - required this.title, - this.scaffoldKey, - this.actions, - this.onGuidePressed, - this.onOptionsPressed}); + final VoidCallback? onRecordPressed; + final bool isRecording; + final String icRecord = 'assets/icons/ic_record_white.png'; + + const CommonScaffold({ + super.key, + required this.body, + required this.title, + this.scaffoldKey, + this.actions, + this.onGuidePressed, + this.onOptionsPressed, + this.onRecordPressed, + this.isRecording = false, + }); @override State createState() => _CommonScaffoldState(); } @@ -60,6 +67,17 @@ class _CommonScaffoldState extends State { ), ), actions: [ + if (widget.onRecordPressed != null) + IconButton( + onPressed: widget.onRecordPressed, + icon: Image.asset( + widget.icRecord, + width: 24, + height: 24, + ), + tooltip: + widget.isRecording ? 'Stop Recording' : 'Start Recording', + ), if (widget.onGuidePressed != null) IconButton( onPressed: widget.onGuidePressed, diff --git a/pubspec.yaml b/pubspec.yaml index a05fcdd00..d49048624 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,10 @@ dependencies: carousel_slider: ^5.1.1 vibration: ^3.1.3 shared_preferences: ^2.5.3 + csv: ^6.0.0 + share_plus: ^11.0.0 + path_provider: ^2.1.5 + file_picker: ^10.2.0 dev_dependencies: