Commit 38fb258
Changed files (5)
lib
model
lib/model/export_import/csv_converter.dart
@@ -0,0 +1,115 @@
+
+import 'package:blood_pressure_app/model/blood_pressure.dart';
+import 'package:blood_pressure_app/model/export_import/column.dart';
+import 'package:blood_pressure_app/model/export_import/legacy_column.dart' show RowDataFieldType;
+import 'package:blood_pressure_app/model/export_import/record_parsing_result.dart';
+import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
+import 'package:blood_pressure_app/model/storage/export_csv_settings_store.dart';
+import 'package:collection/collection.dart';
+import 'package:csv/csv.dart';
+import 'package:flutter/material.dart';
+
+/// Utility class to convert between csv strings and [BloodPressureRecord]s.
+class CsvConverter {
+ /// Create converter between csv strings and [BloodPressureRecord] values that respects settings.
+ CsvConverter(this.settings, this.availableColumns);
+
+ /// Settings that apply for ex- and import.
+ final CsvExportSettings settings;
+
+ /// Columns manager used for ex- and import.
+ final ExportColumnsManager availableColumns;
+
+ /// Create the contents of a csv file from passed records.
+ String create(List<BloodPressureRecord> records) {
+ final columns = settings.exportFieldsConfiguration.getActiveColumns(availableColumns);
+ final table = records.map(
+ (record) => columns.map(
+ (column) => column.encode(record)
+ ).toList()
+ ).toList();
+
+ final csvCreator = ListToCsvConverter(
+ fieldDelimiter: settings.fieldDelimiter,
+ textDelimiter: settings.textDelimiter
+ );
+
+ return csvCreator.convert(table);
+ }
+
+ /// Attempts to parse a csv string.
+ ///
+ /// Validates that the first line of the file contains columns present
+ /// in [availableColumns]. When a column is present multiple times only
+ /// the first one counts.
+ /// A needle pin takes precedent over a color.
+ RecordParsingResult parse(String csvString) {
+ // Turn csv into lines.
+ final lines = (){
+ final converter = CsvToListConverter(
+ fieldDelimiter: settings.fieldDelimiter,
+ textDelimiter: settings.textDelimiter,
+ shouldParseNumbers: false,
+ );
+ final csvLines = converter.convert(csvString, eol: '\r\n');
+ if (csvLines.length < 2) return converter.convert(csvString, eol: '\n');
+ return csvLines;
+ }();
+ if (lines.length < 2) return RecordParsingResult.err(RecordParsingErrorType.emptyFile);
+
+ // Get and validate columns from csv title.
+ final List<ExportColumn> columns = [];
+ for (final titleText in lines.removeAt(0)) {
+ assert(titleText is String);
+ final column = availableColumns.firstWhere(
+ (c) => c.csvTitle == titleText
+ && c.restoreAbleType != null);
+ if (column == null) return RecordParsingResult.err(RecordParsingErrorType.unknownColumn);
+ columns.add(column);
+ }
+ if (columns.where((e) => e.restoreAbleType == RowDataFieldType.timestamp).isEmpty) {
+ return RecordParsingResult.err(RecordParsingErrorType.timeNotRestoreable);
+ }
+
+ // Convert data to records.
+ final List<BloodPressureRecord> records = [];
+ for (final currentLine in lines) {
+ if (currentLine.length < columns.length) {
+ return RecordParsingResult.err(RecordParsingErrorType.expectedMoreFields);
+ }
+
+ final List<(RowDataFieldType, dynamic)> recordPieces = [];
+ for (int idx = 0; idx < columns.length; idx++) {
+ assert(currentLine[idx] is String);
+ final piece = columns[idx].decode(currentLine[idx]);
+ if (piece?.$1 != columns[idx].restoreAbleType) { // validation
+ return RecordParsingResult.err(RecordParsingErrorType.unparsableField);
+ }
+ if (piece != null) recordPieces.add(piece);
+ }
+
+ final DateTime timestamp = recordPieces.firstWhere(
+ (piece) => piece.$1 == RowDataFieldType.timestamp).$2;
+ final int? sys = recordPieces.firstWhereOrNull(
+ (piece) => piece.$1 == RowDataFieldType.sys)?.$2;
+ final int? dia = recordPieces.firstWhereOrNull(
+ (piece) => piece.$1 == RowDataFieldType.dia)?.$2;
+ final int? pul = recordPieces.firstWhereOrNull(
+ (piece) => piece.$1 == RowDataFieldType.pul)?.$2;
+ final String note = recordPieces.firstWhereOrNull(
+ (piece) => piece.$1 == RowDataFieldType.notes)?.$2 ?? '';
+ MeasurementNeedlePin? needlePin = recordPieces.firstWhereOrNull(
+ (piece) => piece.$1 == RowDataFieldType.needlePin)?.$2;
+ if (needlePin == null) {
+ final Color? color = recordPieces.firstWhereOrNull(
+ (piece) => piece.$1 == RowDataFieldType.color)?.$2;
+ if (color != null) needlePin = MeasurementNeedlePin(color);
+ }
+
+ records.add(BloodPressureRecord(timestamp, sys, dia, pul, note, needlePin: needlePin));
+ }
+
+ assert(records.length == lines.length, 'every line should have been parse'); // first line got removed
+ return RecordParsingResult.ok(records);
+ }
+}
lib/model/export_import/legacy_column.dart
@@ -95,6 +95,7 @@ enum RowDataFieldType {
/// Guarantees [String] is returned.
notes,
@Deprecated('use needlePin instead') // TODO: implement conversion to needle pin?
+ /// Guarantees [Color] is returned.
color,
/// Guarantees that the returned type is of type [MeasurementNeedlePin].
needlePin; // TODO implement in ScriptedFormatter
lib/model/export_import/record_formatter.dart
@@ -38,7 +38,6 @@ class ScriptedFormatter implements Formatter {
@override
(RowDataFieldType, dynamic)? decode(String formattedRecord) {
- print('$pattern.decode($formattedRecord)');
if (restoreAbleType == null) return null;
final valueRegex = RegExp(pattern.replaceAll(RegExp(r'\$(TIMESTAMP|COLOR|SYS|DIA|PUL|NOTE)'), '(?<value>.*)'),);
lib/model/export_import/record_parsing_result.dart
@@ -34,14 +34,22 @@ class RecordParsingResult {
}
}
+// TODO: consider converting to sealed class to allow passing error details.
/// Indicates what type error occurred while trying to decode a csv data.
enum RecordParsingErrorType {
/// There are not enough lines in the csv file to parse the record.
emptyFile,
- formatNotReversible,
+ /// There is no column with this csv title that can be reversed.
+ unknownColumn,
- /// There is no column with this csv title.
- unknownColumn
+ /// The current line has less fields than the first line.
+ expectedMoreFields,
+
+ /// There is no column that allows restoring a timestamp.
+ timeNotRestoreable,
+
+ /// The corresponding column couldn't decode a specific field in the csv file.
+ unparsableField,
// TODO ...
}
\ No newline at end of file
lib/model/storage/export_columns_store.dart
@@ -41,13 +41,23 @@ class ExportColumnsManager extends ChangeNotifier { // TODO: separate ExportColu
notifyListeners();
}
+ // TODO test
/// Get any defined column (user or build in) by identifier.
- ExportColumn? getColumn(String identifier) {// TODO test
- return userColumns[identifier]
- ?? NativeColumn.allColumns.where(
- (c) => c.internalIdentifier == identifier).firstOrNull
- ?? BuildInColumn.allColumns.where(
- (c) => c.internalIdentifier == identifier).firstOrNull; // ?? ...
+ ExportColumn? getColumn(String identifier) =>
+ firstWhere((c) => c.internalIdentifier == identifier);
+
+ // TODO test
+ /// Get the first of column that satisfies [test].
+ ///
+ /// Checks in the order:
+ /// 1. userColumns
+ /// 2. NativeColumn
+ /// 3. BuildInColumn
+ ExportColumn? firstWhere(bool Function(ExportColumn) test) {
+ return userColumns.values.where(test).firstOrNull
+ ?? NativeColumn.allColumns.where(test).firstOrNull
+ ?? BuildInColumn.allColumns.where(test).firstOrNull;
+ // ?? ...
}
String toJson() { // TODO: update from and TO json to new style