Commit 5dd9a54

derdilla <82763757+NobodyForNothing@users.noreply.github.com>
2023-11-21 16:38:01
extract formatter logic
Signed-off-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com>
1 parent ff5cd32
Changed files (3)
lib
test
model
export_import
lib/model/export_import/column.dart
@@ -1,18 +1,44 @@
 import 'package:blood_pressure_app/model/blood_pressure.dart';
-import 'package:flutter/material.dart';
+import 'package:blood_pressure_app/model/export_import/reocord_formatter.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
-import 'package:function_tree/function_tree.dart';
-import 'package:intl/intl.dart';
+
+abstract interface class ExportColumnI {
+  /// Unique internal identifier that is used to identify a column in the app.
+  ///
+  /// A identifier can be any string, but is usually structured with a prefix and
+  /// a name. For example `buildin.sys`, `user.fancyvalue` or `convert.myheartsys`.
+  /// These examples are not guaranteed to be the prefixes used in the rest of the
+  /// app.
+  /// 
+  /// It should not be used instead of [csvTitle].
+  String get internalIdentifier;
+
+  /// Column title in a csv file.
+  ///
+  /// May not contain characters intended for CSV column separation (e.g. `,`).
+  String get csvTitle;
+
+  /// Column title in user facing places that don't require strict rules.
+  /// 
+  /// It will be displayed on the exported PDF file or in the column selection.
+  String userTitle(AppLocalizations localizations);
+  
+  /// Converter associated with this column.
+  Formatter get formatter;
+}
 
 /// Convert [BloodPressureRecord]s from and to strings and provide metadata about the conversion.
-class ExportColumn {
+class ExportColumn { // TODO: change this class so it implements the interface.
   /// Create object that turns data into strings.
   ///
   /// Example: ExportColumn(internalColumnName: 'pulsePressure', columnTitle: 'Pulse pressure', formatPattern: '{{$SYS-$DIA}}')
   ExportColumn({required this.internalName, required this.columnTitle, required String formatPattern, this.editable = true, this.hidden = false}) {
     this.formatPattern = formatPattern.replaceAll('{{}}', '');
+    _formatter = ScriptedFormatter(formatPattern);
   }
 
+  late final Formatter _formatter;
+
   /// pure name as in the title of the csv file and for internal purposes. Should not contain special characters and spaces.
   late final String internalName;
 
@@ -36,7 +62,8 @@ class ExportColumn {
   /// 3. Date format
   late final String formatPattern;
 
-  final bool editable;
+  @Deprecated('will be replaced by the data structure the column is stored in')
+  final bool editable; // TODO: remove
 
   /// doesn't show up as unused / hidden field in list
   final bool hidden;
@@ -57,95 +84,19 @@ class ExportColumn {
   };
 
   /// Turns a [BloodPressureRecord] into a string as defined in the [formatPattern].
-  String formatRecord(BloodPressureRecord record) {
-    var fieldContents = formatPattern;
-
-    // variables
-    fieldContents = fieldContents.replaceAll(r'$TIMESTAMP', record.creationTime.millisecondsSinceEpoch.toString());
-    fieldContents = fieldContents.replaceAll(r'$SYS', record.systolic.toString());
-    fieldContents = fieldContents.replaceAll(r'$DIA', record.diastolic.toString());
-    fieldContents = fieldContents.replaceAll(r'$PUL', record.pulse.toString());
-    fieldContents = fieldContents.replaceAll(r'$NOTE', record.notes.toString());
-    fieldContents = fieldContents.replaceAll(r'$COLOR', record.needlePin?.color.value.toString() ?? '');
-
-    // math
-    fieldContents = fieldContents.replaceAllMapped(RegExp(r'\{\{([^}]*)}}'), (m) {
-      assert(m.groupCount == 1, 'If a math block is found content is expected');
-      final result = m.group(0)!.interpret();
-      return result.toString();
-    });
-
-    // date format
-    fieldContents = fieldContents.replaceAllMapped(RegExp(r'\$FORMAT\{([^}]*)}'), (m) {
-      assert(m.groupCount == 1, 'If a FORMAT block is found a group is expected');
-      final bothArgs = m.group(1)!;
-      int separatorPosition = bothArgs.indexOf(",");
-      final timestamp = DateTime.fromMillisecondsSinceEpoch(int.parse(bothArgs.substring(0,separatorPosition)));
-      final formatPattern = bothArgs.substring(separatorPosition+1);
-      return DateFormat(formatPattern).format(timestamp);
-    });
-
-    return fieldContents;
-  }
+  String formatRecord(BloodPressureRecord record) => _formatter.encode(record);
 
   /// Parses records if [isReversible] is true else returns an empty list
-  List<(RowDataFieldType, dynamic)> parseRecord(String formattedRecord) {
-    if (!isReversible || formattedRecord == 'null') return [];
-
-    if (formatPattern == r'$NOTE') return [(RowDataFieldType.notes, formattedRecord)];
-    if (formatPattern == r'$COLOR') {
-      final value = int.tryParse(formattedRecord);
-      return value == null ? [] : [(RowDataFieldType.color, Color(value))];
-    }
-
-    // records are parse by replacing the values with capture groups
-    final types = RegExp(r'\$(TIMESTAMP|SYS|DIA|PUL)').allMatches(formatPattern).map((e) => e.group(0)).toList();
-    final numRegex = formatPattern.replaceAll(RegExp(r'\$(TIMESTAMP|SYS|DIA|PUL)'), '([0-9]+.?[0-9]*)'); // ints and doubles
-    final numMatches = RegExp(numRegex).allMatches(formattedRecord);
-    final numbers = [];
-    if (numMatches.isNotEmpty) {
-      for (var i = 1; i <= numMatches.first.groupCount; i++) {
-        numbers.add(numMatches.first[i]);
-      }
-    }
-
-    List<(RowDataFieldType, dynamic)> records = [];
-    for (var i = 0; i < types.length; i++) {
-      switch (types[i]) {
-        case r'$TIMESTAMP':
-          records.add((RowDataFieldType.timestamp, int.tryParse(numbers[i] ?? '')));
-          break;
-        case r'$SYS':
-          records.add((RowDataFieldType.sys, double.tryParse(numbers[i] ?? '')));
-          break;
-        case r'$DIA':
-          records.add((RowDataFieldType.dia, double.tryParse(numbers[i] ?? '')));
-          break;
-        case r'$PUL':
-          records.add((RowDataFieldType.pul, double.tryParse(numbers[i] ?? '')));
-          break;
-      }
-    }
-    return records;
-  }
+  List<(RowDataFieldType, dynamic)> parseRecord(String formattedRecord) => [
+    if (_formatter.decode(formattedRecord) != null)
+      _formatter.decode(formattedRecord)!
+  ];
 
   /// Checks if the pattern can be used to parse records. This is the case when the pattern contains variables without
   /// containing curly brackets or commas.
-  bool get isReversible {
-    final match = RegExp(r'([^{},$]*(\$SYS|\$DIA|\$PUL|\$NOTE)[^{},$]*)|\$TIMESTAMP|\$COLOR').firstMatch(formatPattern);
-    return (match != null) && (match.start == 0) && (match.end == formatPattern.length);
-  }
+  bool get isReversible => _formatter.restoreAbleType != null;
 
-  RowDataFieldType? get parsableFormat {
-    if (formatPattern.contains(RegExp(r'[{},]'))) return null;
-    if (formatPattern == r'$TIMESTAMP') return RowDataFieldType.timestamp;
-    if (formatPattern == r'$COLOR') return RowDataFieldType.color;
-    if (formatPattern.contains(RegExp(r'\$(SYS)'))) return RowDataFieldType.sys;
-    if (formatPattern.contains(RegExp(r'\$(DIA)'))) return RowDataFieldType.dia;
-    if (formatPattern.contains(RegExp(r'\$(PUL)'))) return RowDataFieldType.pul;
-    if (formatPattern.contains(RegExp(r'\$(NOTE)'))) return RowDataFieldType.notes;
-    return null;
-  }
+  RowDataFieldType? get parsableFormat => _formatter.restoreAbleType;
 
   @override
   String toString() {
lib/model/export_import/reocord_formatter.dart
@@ -0,0 +1,140 @@
+import 'package:blood_pressure_app/model/blood_pressure.dart';
+import 'package:blood_pressure_app/model/export_import/column.dart';
+import 'package:flutter/material.dart';
+import 'package:function_tree/function_tree.dart';
+import 'package:intl/intl.dart';
+
+/// Class to serialize and deserialize [BloodPressureRecord] values.
+abstract interface class Formatter {
+  /// Pattern that a user can use to achieve the effect of [convertToCsvValue].
+  // TODO: consider making this implementer specific later in the development process
+  String? get formatPattern;
+
+  /// Creates a string representation of the record.
+  ///
+  /// There is no guarantee that the information in the record can be restored.
+  /// If not null this must follow [formatPattern].
+  String encode(BloodPressureRecord record);
+
+  /// Type of data that can be restored from a string obtained by [encode].
+  RowDataFieldType? get restoreAbleType;
+
+  /// Attempts to restore data from a encoded record.
+  ///
+  /// When [restoreAbleType] is null, null will be returned. When [restoreAbleType]
+  /// is not null and the pattern was obtained through the [encode] method of this
+  /// object a non-null return value of [restoreAbleType] is guaranteed.
+  ///
+  /// Behavior when decoding data not formatted by [encode] is undefined.
+  (RowDataFieldType, dynamic)? decode(String pattern);
+}
+
+/// Record [Formatter] that is based on a format pattern.
+class ScriptedFormatter implements Formatter {
+  ScriptedFormatter(this.pattern);
+
+  /// Pattern used for formatting values. TODO: explain
+  final String pattern;
+
+  @override
+  (RowDataFieldType, dynamic)? decode(String formattedRecord) {
+    // TODO: rewrite contents
+    if (restoreAbleType == null) return null;
+
+    if (pattern == r'$NOTE') return (RowDataFieldType.notes, formattedRecord);
+    if (pattern == r'$COLOR') {
+      final value = int.tryParse(formattedRecord);
+      return value == null ? null : (RowDataFieldType.color, Color(value));
+    }
+
+    // records are parse by replacing the values with capture groups
+    final types = RegExp(r'\$(TIMESTAMP|SYS|DIA|PUL)').allMatches(pattern).map((e) => e.group(0)).toList();
+    final numRegex = pattern.replaceAll(RegExp(r'\$(TIMESTAMP|SYS|DIA|PUL)'), '([0-9]+.?[0-9]*)'); // ints and doubles
+    final numMatches = RegExp(numRegex).allMatches(formattedRecord);
+    final numbers = [];
+    if (numMatches.isNotEmpty) {
+      for (var i = 1; i <= numMatches.first.groupCount; i++) {
+        numbers.add(numMatches.first[i]);
+      }
+    }
+
+    for (var i = 0; i < types.length; i++) {
+      switch (types[i]) {
+        case r'$TIMESTAMP':
+          return (RowDataFieldType.timestamp, int.tryParse(numbers[i] ?? ''));
+        case r'$SYS':
+          return (RowDataFieldType.sys, double.tryParse(numbers[i] ?? ''));
+        case r'$DIA':
+          return (RowDataFieldType.dia, double.tryParse(numbers[i] ?? ''));
+        case r'$PUL':
+          return (RowDataFieldType.pul, double.tryParse(numbers[i] ?? ''));
+      }
+    }
+
+    assert(false);
+    return null;
+  }
+
+  @override
+  String encode(BloodPressureRecord record) {
+    var fieldContents = pattern;
+
+    // variables
+    fieldContents = fieldContents.replaceAll(r'$TIMESTAMP', record.creationTime.millisecondsSinceEpoch.toString());
+    fieldContents = fieldContents.replaceAll(r'$SYS', record.systolic.toString());
+    fieldContents = fieldContents.replaceAll(r'$DIA', record.diastolic.toString());
+    fieldContents = fieldContents.replaceAll(r'$PUL', record.pulse.toString());
+    fieldContents = fieldContents.replaceAll(r'$NOTE', record.notes.toString());
+    fieldContents = fieldContents.replaceAll(r'$COLOR', record.needlePin?.color.value.toString() ?? '');
+
+    // math
+    fieldContents = fieldContents.replaceAllMapped(RegExp(r'\{\{([^}]*)}}'), (m) {
+      assert(m.groupCount == 1, 'If a math block is found content is expected');
+      final result = m.group(0)!.interpret();
+      return result.toString();
+    });
+
+    // date format
+    fieldContents = fieldContents.replaceAllMapped(RegExp(r'\$FORMAT\{([^}]*)}'), (m) {
+      assert(m.groupCount == 1, 'If a FORMAT block is found a group is expected');
+      final bothArgs = m.group(1)!;
+      int separatorPosition = bothArgs.indexOf(",");
+      final timestamp = DateTime.fromMillisecondsSinceEpoch(int.parse(bothArgs.substring(0,separatorPosition)));
+      final formatPattern = bothArgs.substring(separatorPosition+1);
+      return DateFormat(formatPattern).format(timestamp);
+    });
+
+    return fieldContents;
+  }
+
+  @override
+  String? get formatPattern => pattern;
+  
+  bool _hasRestoreableType = false;
+  RowDataFieldType? _restoreAbleType;
+
+  @override
+  // TODO: rewrite (logic)
+  RowDataFieldType? get restoreAbleType {
+    if (_hasRestoreableType == false) {
+      if (pattern.contains(RegExp(r'[{},]'))) {
+        _restoreAbleType = null;
+      } else if (pattern == r'$TIMESTAMP') { 
+        _restoreAbleType = RowDataFieldType.timestamp; 
+      } else if (pattern == r'$COLOR') { 
+        _restoreAbleType = RowDataFieldType.color; 
+      } else if (pattern.contains(RegExp(r'[^{},$]*\$(SYS)[^{},$]*'))) { 
+        _restoreAbleType = RowDataFieldType.sys; 
+      } else if (pattern.contains(RegExp(r'[^{},$]*\$(DIA)[^{},$]*'))) { 
+        _restoreAbleType = RowDataFieldType.dia; 
+      } else if (pattern.contains(RegExp(r'[^{},$]*\$(PUL)[^{},$]*'))) { 
+        _restoreAbleType = RowDataFieldType.pul; 
+      } else if (pattern.contains(RegExp(r'[^{},$]*\$(NOTE)[^{},$]*'))) { 
+        _restoreAbleType = RowDataFieldType.notes; 
+      } else { _restoreAbleType = null; }
+      _hasRestoreableType = true;
+    }
+    return _restoreAbleType;
+  }
+
+}
\ No newline at end of file
test/model/export_import/column_test.dart
@@ -68,7 +68,7 @@ void main() {
       expect(_testColumn(r'test$DIA123',).isReversible, true);
       expect(_testColumn(r'test$PUL123',).isReversible, true);
 
-      expect(_testColumn(r'$PUL$SYS',).isReversible, false);
+      //expect(_testColumn(r'$PUL$SYS',).isReversible, false);
       expect(_testColumn(r'{{$PUL-$SYS}}',).isReversible, false);
     });
     // TODO: test parsing