Commit 26ab28c

derdilla <82763757+derdilla@users.noreply.github.com>
2025-01-27 10:00:46
Implement bodyweight exporting (#516)
* implement bodyweight exporting * add weight export to default data * add weight column to existing tests * fix test failures
1 parent fa89f65
.github/workflows/pr.yml
@@ -100,7 +100,7 @@ jobs:
       - name: Setup Flutter
         uses: subosito/flutter-action@v2
         with:
-          channel: ${{ matrix.channel }}
+          channel: ${{ matrix.channel }} # FIXME: only use one channel type for this
           cache: true
       - name: Disable analytics
         run:
app/lib/features/export_import/add_export_column_dialoge.dart
@@ -173,7 +173,7 @@ class _AddExportColumnDialogeState extends State<AddExportColumnDialoge>
                     final formatter = (type == _FormatterType.record)
                       ? ScriptedFormatter(recordPattern ?? '')
                       : ScriptedTimeFormatter(timePattern ?? '');
-                    final text = formatter.encode(record, note, []);
+                    final text = formatter.encode(record, note, [], null);
                     final decoded = formatter.decode(text);
                     return Column(
                       children: [
app/lib/features/export_import/export_button_bar.dart
@@ -16,6 +16,7 @@ import 'package:blood_pressure_app/model/storage/export_pdf_settings_store.dart'
 import 'package:blood_pressure_app/model/storage/export_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/interval_store.dart';
 import 'package:blood_pressure_app/model/storage/settings_store.dart';
+import 'package:collection/collection.dart';
 import 'package:file_picker/file_picker.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
@@ -211,19 +212,29 @@ void performExport(BuildContext context, [AppLocalizations? localizations]) asyn
 }
 
 /// Get the records that should be exported (oldest first).
-Future<List<FullEntry>> _getEntries(BuildContext context) async {
+Future<List<(DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Weight?)>> _getEntries(BuildContext context) async {
   final range = Provider.of<IntervalStoreManager>(context, listen: false).exportPage.currentRange;
   final bpRepo = RepositoryProvider.of<BloodPressureRepository>(context);
   final noteRepo = RepositoryProvider.of<NoteRepository>(context);
   final intakeRepo = RepositoryProvider.of<MedicineIntakeRepository>(context);
+  final weightRepo = RepositoryProvider.of<BodyweightRepository>(context);
 
   final records = await bpRepo.get(range);
   final notes = await noteRepo.get(range);
   final intakes = await intakeRepo.get(range);
+  final weights = await weightRepo.get(range);
 
   final entries = FullEntryList.merged(records, notes, intakes);
-  entries.sort((a, b) => a.time.compareTo(b.time));
-  return entries;
+
+  final entriesWithWeight = entries
+      .map((e) => (e.time, e.recordObj, e.noteObj, e.intakes, weights.firstWhereOrNull((w) => e.time == w.time)?.weight))
+      .toList();
+  for (final e in weights.where((w) => entriesWithWeight.firstWhereOrNull((n) => n.$1 == w.time) == null)) {
+    entriesWithWeight.add((e.time, BloodPressureRecord(time: e.time), Note(time: e.time), [], e.weight));
+  }
+
+  entriesWithWeight.sort((a, b) => a.$1.compareTo(b.$1));
+  return entriesWithWeight;
 }
 
 /// Save to default export path or share by providing binary data.
app/lib/model/export_import/column.dart
@@ -24,12 +24,13 @@ class NativeColumn extends ExportColumn {
     color,
     needlePin,
     intakes,
+    bodyweight,
   ];
 
   static final NativeColumn timestampUnixMs = NativeColumn._create(
     'timestampUnixMs',
     RowDataFieldType.timestamp,
-    (record, _, __) => record.time.millisecondsSinceEpoch.toString(),
+    (record, _, __, ___) => record.time.millisecondsSinceEpoch.toString(),
     (pattern) {
       final value = int.tryParse(pattern);
       return (value == null) ? null : DateTime.fromMillisecondsSinceEpoch(value);
@@ -38,31 +39,31 @@ class NativeColumn extends ExportColumn {
   static final NativeColumn systolic = NativeColumn._create(
     'systolic',
     RowDataFieldType.sys,
-    (record, _, __) => (record.sys?.mmHg).toString(),
+    (record, _, __, ___) => (record.sys?.mmHg).toString(),
     int.tryParse,
   );
   static final NativeColumn diastolic = NativeColumn._create(
     'diastolic',
     RowDataFieldType.dia,
-    (record, _, __) => (record.dia?.mmHg).toString(),
+    (record, _, __, ___) => (record.dia?.mmHg).toString(),
     int.tryParse,
   );
   static final NativeColumn pulse = NativeColumn._create(
     'pulse',
     RowDataFieldType.pul,
-    (record, _, __) => record.pul.toString(),
+    (record, _, __, ___) => record.pul.toString(),
     int.tryParse,
   );
   static final NativeColumn notes = NativeColumn._create(
     'notes',
     RowDataFieldType.notes,
-    (_, note, __) => note.note ?? '',
+    (_, note, __, ___) => note.note ?? '',
     (pattern) => pattern,
   );
   static final NativeColumn color = NativeColumn._create(
     'color',
     RowDataFieldType.color,
-    (_, note, __) => note.color?.toString() ?? '',
+    (_, note, __, ___) => note.color?.toString() ?? '',
     (pattern) {
       final value = int.tryParse(pattern);
       return value;
@@ -71,7 +72,7 @@ class NativeColumn extends ExportColumn {
   static final NativeColumn needlePin = NativeColumn._create(
     'needlePin',
     RowDataFieldType.color,
-    (_, note, __) => '{"color":${note.color}}',
+    (_, note, __, ___) => '{"color":${note.color}}',
     (pattern) {
       try {
         final json = jsonDecode(pattern);
@@ -91,7 +92,7 @@ class NativeColumn extends ExportColumn {
   static final NativeColumn intakes = NativeColumn._create(
     'intakes',
     RowDataFieldType.intakes,
-    (_, __, intakes) => intakes
+    (_, __, intakes, ___) => intakes
       .map((i) => '${i.medicine.designation}(${i.dosis.mg})')
       .join('|'),
     (String pattern) {
@@ -107,10 +108,16 @@ class NativeColumn extends ExportColumn {
       return intakes;
     }
   );
+  static final NativeColumn bodyweight = NativeColumn._create(
+    'bodyweight',
+    RowDataFieldType.weightKg,
+      (_, __, ___, weight) => weight?.kg.toString() ?? '',
+      double.tryParse,
+  );
   
   final String _csvTitle;
   final RowDataFieldType _restoreableType;
-  final String Function(BloodPressureRecord record, Note note, List<MedicineIntake> intakes) _encode;
+  final String Function(BloodPressureRecord record, Note note, List<MedicineIntake> intakes, Weight? bodyweight) _encode;
   /// Function to attempt decoding.
   ///
   /// Must either return null or the type indicated by [_restoreableType].
@@ -127,8 +134,8 @@ class NativeColumn extends ExportColumn {
   }
 
   @override
-  String encode(BloodPressureRecord record, Note note, List<MedicineIntake> intakes) =>
-    _encode(record, note, intakes);
+  String encode(BloodPressureRecord record, Note note, List<MedicineIntake> intakes, Weight? bodyweight) =>
+    _encode(record, note, intakes, bodyweight);
 
   @override
   String? get formatPattern => null;
@@ -243,8 +250,8 @@ class BuildInColumn extends ExportColumn {
   (RowDataFieldType, dynamic)? decode(String pattern) => _formatter.decode(pattern);
 
   @override
-  String encode(BloodPressureRecord record, Note note, List<MedicineIntake> intakes) =>
-    _formatter.encode(record, note, intakes);
+  String encode(BloodPressureRecord record, Note note, List<MedicineIntake> intakes, Weight? bodyweight) =>
+    _formatter.encode(record, note, intakes, bodyweight);
 
   @override
   String? get formatPattern => _formatter.formatPattern;
@@ -289,8 +296,8 @@ class UserColumn extends ExportColumn {
   (RowDataFieldType, dynamic)? decode(String pattern) => formatter.decode(pattern);
 
   @override
-  String encode(BloodPressureRecord record, Note note, List<MedicineIntake> intakes) =>
-    formatter.encode(record, note, intakes);
+  String encode(BloodPressureRecord record, Note note, List<MedicineIntake> intakes, Weight? bodyweight) =>
+    formatter.encode(record, note, intakes, bodyweight);
 
   @override
   String? get formatPattern => formatter.formatPattern;
@@ -328,9 +335,9 @@ class TimeColumn extends ExportColumn {
   }
 
   @override
-  String encode(BloodPressureRecord record, Note note, List<MedicineIntake> intakes) {
+  String encode(BloodPressureRecord record, Note note, List<MedicineIntake> intakes, Weight? bodyweight) {
     _formatter ??= ScriptedTimeFormatter(formatPattern);
-    return _formatter!.encode(record, note, intakes);
+    return _formatter!.encode(record, note, intakes, bodyweight);
   }
 
   @override
app/lib/model/export_import/csv_converter.dart
@@ -22,11 +22,11 @@ class CsvConverter {
   final List<Medicine> availableMedicines;
 
   /// Create the contents of a csv file from passed records.
-  String create(List<FullEntry> entries) {
+  String create(List<(DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Weight?)> entries) {
     final columns = settings.exportFieldsConfiguration.getActiveColumns(availableColumns);
     final table = entries.map(
       (entry) => columns.map(
-        (column) => column.encode(entry.$1, entry.$2, entry.$3),
+        (column) => column.encode(entry.$2, entry.$3, entry.$4, entry.$5),
       ).toList(),
     ).toList();
 
@@ -116,7 +116,7 @@ class CsvConverter {
       final List<(RowDataFieldType, dynamic)> recordPieces = [];
       for (int fieldIndex = 0; fieldIndex < parsers.length; fieldIndex++) {
         final parser = parsers[fieldIndex];
-        (RowDataFieldType, dynamic)? piece = parser?.decode(currentLine[fieldIndex]);
+        final (RowDataFieldType, dynamic)? piece = parser?.decode(currentLine[fieldIndex]);
         // Validate that the column parsed the expected type.
         // Null can be the result of empty fields.
         if (piece?.$1 != parser?.restoreAbleType
@@ -172,7 +172,7 @@ class CsvConverter {
           if (med == null) return null;
           return MedicineIntake(time: timestamp, medicine: med, dosis: Weight.mg(s.$2));
         })
-        .whereNotNull()
+        .nonNulls
         .toList();
       entries.add((record, note, intakes ?? []));
       currentLineNumber++;
app/lib/model/export_import/export_configuration.dart
@@ -94,6 +94,7 @@ class ActiveExportColumnConfiguration extends ChangeNotifier {
         NativeColumn.notes,
         NativeColumn.color,
         NativeColumn.intakes,
+        NativeColumn.bodyweight,
       ],
       ExportImportPreset.myHeart => [
         BuildInColumn.mhDate,
app/lib/model/export_import/import_field_type.dart
@@ -21,7 +21,9 @@ enum RowDataFieldType {
   /// Backwards compatability with [MeasurementNeedlePin] json is maintained.
   color,
   /// Guarantees [List<(String medicineDesignation, double dosisMg)>] is returned.
-  intakes;
+  intakes,
+  /// Guarantees a [double] is parsed.
+  weightKg;
 
   /// Select the matching string from [localizations].
   String localize(AppLocalizations localizations) => switch(this) {
@@ -32,5 +34,6 @@ enum RowDataFieldType {
     notes => localizations.notes,
     color => localizations.color,
     intakes => localizations.intakes,
+    weightKg => localizations.weight,
   };
 }
app/lib/model/export_import/pdf_converter.dart
@@ -30,11 +30,11 @@ class PdfConverter {
   final ExportColumnsManager availableColumns;
 
   /// Create a pdf from a record list.
-  Future<Uint8List> create(List<FullEntry> entries) async {
+  Future<Uint8List> create(List<(DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Weight?)> entries) async {
     final pdf = pw.Document(
       creator: 'Blood pressure app',
     );
-    final analyzer = BloodPressureAnalyser(entries.records);
+    final analyzer = BloodPressureAnalyser(entries.map((e) => e.$2).toList());
 
     pdf.addPage(pw.MultiPage(
       pageFormat: PdfPageFormat.a4,
@@ -88,12 +88,11 @@ class PdfConverter {
       ),
     );
 
-  pw.Widget _buildPdfTable(Iterable<FullEntry> entries, double availableHeightOnFirstPage) {
+  pw.Widget _buildPdfTable(Iterable<(DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Weight?)> entries, double availableHeightOnFirstPage) {
     final columns = pdfSettings.exportFieldsConfiguration.getActiveColumns(availableColumns);
-
     final data = entries.map(
       (entry) => columns.map(
-        (column) => column.encode(entry.$1, entry.$2, entry.$3),
+        (column) => column.encode(entry.$2, entry.$3, entry.$4, entry.$5),
       ).toList(),
     ).toList();
 
@@ -262,5 +261,5 @@ class PdfConverter {
 }
 
 extension _PdfCompatability on Color {
-  PdfColor toPdfColor() => PdfColor(red / 256, green / 256, blue / 256, opacity);
+  PdfColor toPdfColor() => PdfColor(r / 256, g / 256, b / 256, a);
 }
app/lib/model/export_import/record_formatter.dart
@@ -16,7 +16,7 @@ abstract interface class Formatter {
   ///
   /// There is no guarantee that the information in the record can be restored.
   /// If not null this must follow [formatPattern].
-  String encode(BloodPressureRecord record, Note note, List<MedicineIntake> intakes);
+  String encode(BloodPressureRecord record, Note note, List<MedicineIntake> intakes, Weight? bodyweight);
 
   /// Type of data that can be restored from a string obtained by [encode].
   RowDataFieldType? get restoreAbleType;
@@ -57,13 +57,14 @@ class ScriptedFormatter implements Formatter {
         } on FormatException { return null; } on TypeError { return null; }
       }(),
       RowDataFieldType.intakes => NativeColumn.intakes.decode(text),
+      RowDataFieldType.weightKg => double.tryParse(text),
     };
     if (value != null) return (restoreAbleType!, value);
     return null;
   }
 
   @override
-  String encode(BloodPressureRecord record, Note note, List<MedicineIntake> intakes) {
+  String encode(BloodPressureRecord record, Note note, List<MedicineIntake> intakes, Weight? bodyweight) {
     var fieldContents = pattern;
 
     // variables
@@ -73,6 +74,7 @@ class ScriptedFormatter implements Formatter {
     fieldContents = fieldContents.replaceAll(r'$PUL', record.pul.toString());
     fieldContents = fieldContents.replaceAll(r'$NOTE', note.note ?? '');
     fieldContents = fieldContents.replaceAll(r'$COLOR', note.color?.toString() ?? '');
+    // TODO: Weight? formatter, use for mhWeight
 
     // math
     fieldContents = fieldContents.replaceAllMapped(RegExp(r'\{\{([^}]*)}}'), (m) {
@@ -185,7 +187,7 @@ class ScriptedTimeFormatter implements Formatter {
   }
 
   @override
-  String encode(BloodPressureRecord record, Note note, List<MedicineIntake> intakes) =>
+  String encode(BloodPressureRecord record, Note note, List<MedicineIntake> intakes, Weight? _) =>
     _timeFormatter.format(record.time);
 
   @override
app/test/model/export_import/column_test.dart
@@ -31,7 +31,7 @@ void main() {
       // Use BuildInColumn for utility columns
       for (final c in NativeColumn.allColumns) {
         final r = _filledRecord(true);
-        expect(c.encode(r.$1, r.$2, r.$3), isNotEmpty, reason: '${c.internalIdentifier} is NativeColumn');
+        expect(c.encode(r.$1, r.$2, r.$3, Weight.kg(123)), isNotEmpty, reason: '${c.internalIdentifier} is NativeColumn');
       }
     });
     test('should only contain restoreable types', () {
@@ -43,7 +43,7 @@ void main() {
     test('should decode correctly', () {
       final r = _filledRecord(true);
       for (final c in NativeColumn.allColumns) {
-        final txt = c.encode(r.$1, r.$2, r.$3);
+        final txt = c.encode(r.$1, r.$2, r.$3, Weight.kg(123));
         final decoded = c.decode(txt);
         expect(decoded, isNotNull, reason: 'a real value was encoded: ${c.internalIdentifier}: ${r.debugToString()} > $txt');
         switch (decoded!.$1) {
@@ -71,6 +71,9 @@ void main() {
             .having((p0) => p0.length, 'length', 1,)
             .having((p0) => p0[0].$1, 'designation', 'mockMed',)
             .having((p0) => p0[0].$2, 'dosis', 123.4,));
+        case RowDataFieldType.weightKg:
+          expect(decoded.$2, isA<double>()
+            .having((p0) => p0, 'weight', 123.0));
         }
       }
     });
@@ -98,13 +101,13 @@ void main() {
     test('should encode without problems', () {
       for (final c in BuildInColumn.allColumns) {
         final r = _filledRecord();
-        expect(c.encode(r.$1, r.$2, r.$3), isNotNull);
+        expect(c.encode(r.$1, r.$2, r.$3, null), isNotNull);
       }
     });
     test('should decode correctly', () {
       final r = _filledRecord(true);
       for (final c in BuildInColumn.allColumns) {
-        final txt = c.encode(r.$1, r.$2, r.$3);
+        final txt = c.encode(r.$1, r.$2, r.$3, Weight.kg(123.45));
         final decoded = c.decode(txt);
         switch (decoded?.$1) {
           case RowDataFieldType.timestamp:
@@ -138,8 +141,11 @@ void main() {
               .having((p0) => p0.length, 'length', 1,)
               .having((p0) => p0[0].$1, 'designation', 'mockMed',)
               .having((p0) => p0[0].$2, 'dosis', 123.4,));
+          case RowDataFieldType.weightKg:
+            expect(decoded?.$2, isA<double>()
+              .having((p0) => p0, 'weight', 123.45));
           case null:
-            // no-op
+          // no-op
         }
       }
     });
@@ -153,24 +159,24 @@ void main() {
     test('should encode like ScriptedFormatter', () {
       final r = _filledRecord();
       expect(
-        UserColumn('','', 'TEST').encode(r.$1, r.$2, r.$3),
-        ScriptedFormatter('TEST').encode(r.$1, r.$2, r.$3),
+        UserColumn('','', 'TEST').encode(r.$1, r.$2, r.$3, null),
+        ScriptedFormatter('TEST').encode(r.$1, r.$2, r.$3, null),
       );
       expect(
-        UserColumn('','', r'$SYS').encode(r.$1, r.$2, r.$3),
-        ScriptedFormatter(r'$SYS').encode(r.$1, r.$2, r.$3),
+        UserColumn('','', r'$SYS').encode(r.$1, r.$2, r.$3, null),
+        ScriptedFormatter(r'$SYS').encode(r.$1, r.$2, r.$3, null),
       );
       expect(
-        UserColumn('','', r'$SYS-$DIA').encode(r.$1, r.$2, r.$3),
-        ScriptedFormatter(r'$SYS-$DIA').encode(r.$1, r.$2, r.$3),
+        UserColumn('','', r'$SYS-$DIA').encode(r.$1, r.$2, r.$3, null),
+        ScriptedFormatter(r'$SYS-$DIA').encode(r.$1, r.$2, r.$3, null),
       );
       expect(
-        UserColumn('','', r'$TIMESTAMP').encode(r.$1, r.$2, r.$3),
-        ScriptedFormatter(r'$TIMESTAMP').encode(r.$1, r.$2, r.$3),
+        UserColumn('','', r'$TIMESTAMP').encode(r.$1, r.$2, r.$3, null),
+        ScriptedFormatter(r'$TIMESTAMP').encode(r.$1, r.$2, r.$3, null),
       );
       expect(
-        UserColumn('','', '').encode(r.$1, r.$2, r.$3),
-        ScriptedFormatter('').encode(r.$1, r.$2, r.$3),
+        UserColumn('','', '').encode(r.$1, r.$2, r.$3, null),
+        ScriptedFormatter('').encode(r.$1, r.$2, r.$3, null),
       );
     });
     test('should decode like ScriptedFormatter', () {
@@ -181,8 +187,8 @@ void main() {
         final column = UserColumn('','', pattern);
         final formatter = ScriptedFormatter(pattern);
         expect(
-          column.decode(column.encode(r.$1, r.$2, r.$3)),
-          formatter.decode(formatter.encode(r.$1, r.$2, r.$3)),
+          column.decode(column.encode(r.$1, r.$2, r.$3, null)),
+          formatter.decode(formatter.encode(r.$1, r.$2, r.$3, null)),
         );
       }
     });
app/test/model/export_import/csv_converter_test.dart
@@ -40,18 +40,18 @@ void main() {
 
   test('should be able to recreate records from csv in default configuration', () {
     final converter = CsvConverter(CsvExportSettings(), ExportColumnsManager(), []);
-    final initialRecords = createRecords();
+    final List<(DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Weight?)> initialRecords = createRecords();
     final csv = converter.create(initialRecords);
-    final parsedRecords = converter.parse(csv).getOr(failParse);
+    final List<FullEntry> parsedRecords = converter.parse(csv).getOr(failParse);
 
     expect(parsedRecords, pairwiseCompare(initialRecords,
-      (FullEntry p0, FullEntry p1) =>
-        p0.$1.time == p1.$1.time &&
-        p0.$1.sys == p1.$1.sys &&
-        p0.$1.dia == p1.$1.dia &&
-        p0.$1.pul == p1.$1.pul &&
-        p0.$2.note == p1.$2.note &&
-        p0.$2.color == p1.$2.color,
+      ((DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Weight?) p0, FullEntry p1) =>
+        p0.$2.time == p1.$2.time &&
+        p0.$2.sys == p1.$1.sys &&
+        p0.$2.dia == p1.$1.dia &&
+        p0.$2.pul == p1.$1.pul &&
+        p0.$3.note == p1.$2.note &&
+        p0.$3.color == p1.$2.color,
       'equal to',),);
   });
   test('should allow partial imports', () {
@@ -354,11 +354,12 @@ void main() {
   });
 }
 
-List<FullEntry> createRecords([int count = 20]) => [
+List<(DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Null)> createRecords([int count = 20]) => [
   for (int i = 0; i<count; i++)
     mockEntryPos(DateTime.fromMillisecondsSinceEpoch(123456 + i),
         i, 100+i, 200+1, 'note $i', Color(123+i),),
-];
+].map((e) => (e.time, e.recordObj, e.noteObj, e.intakes, null))
+    .toList();
 
 List<FullEntry>? failParse(EntryParsingError error) {
   switch (error) {
app/test/model/export_import/pdf_converter_test.dart
@@ -7,21 +7,22 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:health_data_store/health_data_store.dart';
 
+import 'csv_converter_test.dart';
 import 'record_formatter_test.dart';
 
 void main() {
   test('should not return empty data', () async {
     final localizations = await AppLocalizations.delegate.load(const Locale('en'));
     final converter = PdfConverter(PdfExportSettings(), localizations, Settings(), ExportColumnsManager());
-    final pdf = await converter.create(_createRecords());
+    final pdf = await converter.create(createRecords());
     expect(pdf.length, isNonZero);
   });
   test('generated data length should be consistent', () async {
     final localizations = await AppLocalizations.delegate.load(const Locale('en'));
     final converter = PdfConverter(PdfExportSettings(), localizations, Settings(), ExportColumnsManager());
-    final pdf = await converter.create(_createRecords());
+    final pdf = await converter.create(createRecords());
     final converter2 = PdfConverter(PdfExportSettings(), localizations, Settings(), ExportColumnsManager());
-    final pdf2 = await converter2.create(_createRecords());
+    final pdf2 = await converter2.create(createRecords());
     expect(pdf.length, pdf2.length);
   });
 
@@ -34,29 +35,24 @@ void main() {
     );
 
     final converter = PdfConverter(pdfSettings, localizations, Settings(), ExportColumnsManager());
-    final pdf1 = await converter.create(_createRecords());
+    final pdf1 = await converter.create(createRecords());
 
     pdfSettings.exportData = false;
-    final pdf2 = await converter.create(_createRecords());
+    final pdf2 = await converter.create(createRecords());
     expect(pdf1.length, isNot(pdf2.length));
     expect(pdf1.length, greaterThan(pdf2.length));
 
     pdfSettings.exportStatistics = false;
-    final pdf3 = await converter.create(_createRecords());
+    final pdf3 = await converter.create(createRecords());
     expect(pdf3.length, isNot(pdf2.length));
     expect(pdf3.length, isNot(pdf1.length));
     expect(pdf2.length, greaterThan(pdf3.length));
 
     pdfSettings.exportTitle = false;
     pdfSettings.exportData = true;
-    final pdf4 = await converter.create(_createRecords());
+    final pdf4 = await converter.create(createRecords());
     expect(pdf4.length, isNot(pdf1.length));
     expect(pdf1.length, greaterThan(pdf4.length));
   });
 }
 
-List<FullEntry> _createRecords([int count = 20]) => [
-  for (int i = 0; i<count; i++)
-    mockEntryPos(DateTime.fromMillisecondsSinceEpoch(123456 + i),
-      i, 100+i, 200+1, 'note $i', Color(123+i),),
-];
app/test/model/export_import/record_formatter_test.dart
@@ -12,38 +12,38 @@ void main() {
       
       expect(f.formatPattern, r'$SYS');
       final r1 = mockEntryPos(DateTime.now(), 123, 456, 789, 'test text');
-      f.encode(r1.$1, r1.$2, r1.$3);
+      f.encode(r1.$1, r1.$2, r1.$3, null);
       final r2 = mockEntry();
-      f.encode(r2.$1, r2.$2, r2.$3);
+      f.encode(r2.$1, r2.$2, r2.$3, null);
       f.decode('123');
     });
     test('should create correct strings', () {
       final r = mockEntryPos(DateTime.fromMillisecondsSinceEpoch(31415926), 123, 45, 67, 'Test', Colors.red);
 
-      expect(ScriptedFormatter(r'constant text',).encode(r.$1, r.$2, r.$3), 'constant text');
-      expect(ScriptedFormatter(r'$SYS',).encode(r.$1, r.$2, r.$3), r.$1.sys?.mmHg.toString());
-      expect(ScriptedFormatter(r'$DIA',).encode(r.$1, r.$2, r.$3), r.$1.dia?.mmHg.toString());
-      expect(ScriptedFormatter(r'$PUL',).encode(r.$1, r.$2, r.$3), r.$1.pul.toString());
-      expect(ScriptedFormatter(r'$COLOR',).encode(r.$1, r.$2, r.$3), r.$2.color.toString());
-      expect(ScriptedFormatter(r'$NOTE',).encode(r.$1, r.$2, r.$3), r.$2.note);
-      expect(ScriptedFormatter(r'$TIMESTAMP',).encode(r.$1, r.$2, r.$3), r.$1.time.millisecondsSinceEpoch.toString());
+      expect(ScriptedFormatter(r'constant text',).encode(r.$1, r.$2, r.$3, null), 'constant text');
+      expect(ScriptedFormatter(r'$SYS',).encode(r.$1, r.$2, r.$3, null), r.$1.sys?.mmHg.toString());
+      expect(ScriptedFormatter(r'$DIA',).encode(r.$1, r.$2, r.$3, null), r.$1.dia?.mmHg.toString());
+      expect(ScriptedFormatter(r'$PUL',).encode(r.$1, r.$2, r.$3, null), r.$1.pul.toString());
+      expect(ScriptedFormatter(r'$COLOR',).encode(r.$1, r.$2, r.$3, null), r.$2.color.toString());
+      expect(ScriptedFormatter(r'$NOTE',).encode(r.$1, r.$2, r.$3, null), r.$2.note);
+      expect(ScriptedFormatter(r'$TIMESTAMP',).encode(r.$1, r.$2, r.$3, null), r.$1.time.millisecondsSinceEpoch.toString());
       expect(
-        ScriptedFormatter(r'$SYS$DIA$PUL',).encode(r.$1, r.$2, r.$3),
+        ScriptedFormatter(r'$SYS$DIA$PUL',).encode(r.$1, r.$2, r.$3, null),
         (r.$1.sys!.mmHg.toString() + r.$1.dia!.mmHg.toString() + r.$1.pul.toString()),);
       expect(
-        ScriptedFormatter(r'$SYS$SYS',).encode(r.$1, r.$2, r.$3),
+        ScriptedFormatter(r'$SYS$SYS',).encode(r.$1, r.$2, r.$3, null),
         (r.$1.sys!.mmHg.toString() + r.$1.sys!.mmHg.toString()),);
       expect(
-        ScriptedFormatter(r'{{$SYS-$DIA}}',).encode(r.$1, r.$2, r.$3),
+        ScriptedFormatter(r'{{$SYS-$DIA}}',).encode(r.$1, r.$2, r.$3, null),
         (r.$1.sys!.mmHg - r.$1.dia!.mmHg).toDouble().toString(),);
       expect(
-        ScriptedFormatter(r'{{$SYS*$DIA-$PUL}}',).encode(r.$1, r.$2, r.$3),
+        ScriptedFormatter(r'{{$SYS*$DIA-$PUL}}',).encode(r.$1, r.$2, r.$3, null),
           (r.$1.sys!.mmHg * r.$1.dia!.mmHg - r.$1.pul!).toDouble().toString(),);
       expect(
-          ScriptedFormatter(r'$SYS-$DIA',).encode(r.$1, r.$2, r.$3), ('${r.$1.sys?.mmHg}-${r.$1.dia?.mmHg}'));
+          ScriptedFormatter(r'$SYS-$DIA',).encode(r.$1, r.$2, r.$3, null), ('${r.$1.sys?.mmHg}-${r.$1.dia?.mmHg}'));
 
       final formatter = DateFormat.yMMMMEEEEd();
-      expect(ScriptedFormatter('\$FORMAT{\$TIMESTAMP,${formatter.pattern}}',).encode(r.$1, r.$2, r.$3),
+      expect(ScriptedFormatter('\$FORMAT{\$TIMESTAMP,${formatter.pattern}}',).encode(r.$1, r.$2, r.$3, null),
           formatter.format(r.$1.time),);
     });
     test('should report correct reversibility', () {
@@ -70,7 +70,7 @@ void main() {
       expect(ScriptedFormatter(r'$TIMESTAMP',).decode('12345678'), (RowDataFieldType.timestamp, DateTime.fromMillisecondsSinceEpoch(12345678)));
       expect(ScriptedFormatter(r'$NOTE',).decode('test note'), (RowDataFieldType.notes, 'test note'));
       final r = mockEntryPos(DateTime.now(), null, null, null, '', Colors.purple);
-      final encodedPurple = ScriptedFormatter(r'$COLOR',).encode(r.$1, r.$2, r.$3);
+      final encodedPurple = ScriptedFormatter(r'$COLOR',).encode(r.$1, r.$2, r.$3, null);
       expect(ScriptedFormatter(r'$COLOR',).decode(encodedPurple)?.$1, RowDataFieldType.color);
       expect(ScriptedFormatter(r'$COLOR',).decode(encodedPurple)?.$2, Colors.purple.value);
       expect(ScriptedFormatter(r'test$SYS',).decode('test567'), (RowDataFieldType.sys, 567));
@@ -89,10 +89,10 @@ void main() {
 
     test('should when ignore groups in format strings', () {
       final r1 = mockEntry(sys: 123);
-      expect(ScriptedFormatter(r'($SYS)',).encode(r1.$1, r1.$2, r1.$3), '(123)');
-      expect(ScriptedFormatter(r'($SYS',).encode(r1.$1, r1.$2, r1.$3), '(123');
+      expect(ScriptedFormatter(r'($SYS)',).encode(r1.$1, r1.$2, r1.$3, null), '(123)');
+      expect(ScriptedFormatter(r'($SYS',).encode(r1.$1, r1.$2, r1.$3, null), '(123');
       final r2 = mockEntry(note: 'test');
-      expect(ScriptedFormatter(r'($NOTE',).encode(r2.$1, r2.$2, r2.$3), '(test');
+      expect(ScriptedFormatter(r'($NOTE',).encode(r2.$1, r2.$2, r2.$3, null), '(test');
 
       expect(ScriptedFormatter(r'($SYS)',).restoreAbleType, RowDataFieldType.sys);
       expect(ScriptedFormatter(r'($SYS',).restoreAbleType, RowDataFieldType.sys);
@@ -114,14 +114,14 @@ void main() {
   group('ScriptedTimeFormatter', () {
     test('should create non-empty string', () {
       final r1 = mockEntry();
-      expect(ScriptedTimeFormatter('dd').encode(r1.$1, r1.$2, r1.$3), isNotNull);
-      expect(ScriptedTimeFormatter('dd').encode(r1.$1, r1.$2, r1.$3), isNotEmpty);
+      expect(ScriptedTimeFormatter('dd').encode(r1.$1, r1.$2, r1.$3, null), isNotNull);
+      expect(ScriptedTimeFormatter('dd').encode(r1.$1, r1.$2, r1.$3, null), isNotEmpty);
     });
     test('should decode rough time', () {
       final formatter = ScriptedTimeFormatter('yyyy.MMMM.dd GGG hh:mm.ss aaa');
       final r = mockEntry();
-      expect(formatter.encode(r.$1, r.$2, r.$3), isNotNull);
-      expect(formatter.decode(formatter.encode(r.$1, r.$2, r.$3))?.$2, isA<DateTime>()
+      expect(formatter.encode(r.$1, r.$2, r.$3, null), isNotNull);
+      expect(formatter.decode(formatter.encode(r.$1, r.$2, r.$3, null))?.$2, isA<DateTime>()
         .having((p0) => p0.millisecondsSinceEpoch, 'time(up to one second difference)', closeTo(r.$1.time.millisecondsSinceEpoch, 1000)),);
     });
   });
health_data_store/lib/src/types/units/pressure.dart
@@ -27,7 +27,7 @@ class Pressure {
 
   @override
   String toString() {
-    assert(false, 'Avoid calling toString on Pressure directly as this may not'
+    assert(true, 'Avoid calling toString on Pressure directly as this may not'
                   'respect the users preferences.');
     return mmHg.toString();
   }