Commit 1246536

derdilla <82763757+NobodyForNothing@users.noreply.github.com>
2024-06-21 14:19:30
extract code to merge FullEntry lists
Signed-off-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com>
1 parent 04bb9c9
Changed files (5)
app
lib
components
screens
subsettings
health_data_store
lib
src
test
app/lib/components/measurement_list/intake_list_entry.dart
@@ -1,57 +0,0 @@
-import 'dart:async';
-
-import 'package:blood_pressure_app/components/measurement_list/measurement_list_entry.dart';
-import 'package:blood_pressure_app/model/storage/settings_store.dart';
-import 'package:flutter/material.dart';
-import 'package:health_data_store/health_data_store.dart';
-import 'package:intl/intl.dart';
-
-/// Medicine intake to display in a list.
-class IntakeListEntry extends StatelessWidget {
-  /// Display a medicine intake on a list tile.
-  const IntakeListEntry({super.key,
-    required this.settings,
-    required this.intake,
-    this.delete,
-  });
-
-  /// Settings to customize basic behavior.
-  final Settings settings;
-
-  /// Intake that provides the data to display.
-  final MedicineIntake intake;
-
-  /// Function to delete this intake.
-  ///
-  /// Gets called after the delete button has been pressed and the deletion got
-  /// confirmed.
-  /// When null no deletion button is displayed.
-  final FutureOr<void> Function()? delete;
-
-  @override
-  Widget build(BuildContext context) => ListTile(
-    title: Text(intake.medicine.designation),
-    subtitle: Text(DateFormat(settings.dateFormatString).format(intake.time)),
-    trailing: Row(
-      mainAxisSize: MainAxisSize.min,
-      children: [
-        Text(intake.dosis.toString()),
-        if (delete != null)
-          IconButton(
-            onPressed: () async {
-              bool confirmedDeletion = true;
-              if (settings.confirmDeletion) {
-                confirmedDeletion = await showConfirmDeletionDialoge(context);
-              }
-              if (confirmedDeletion) delete!();
-            },
-            icon: const Icon(Icons.delete),
-          ),
-      ],
-    ),
-    leading: const Icon(Icons.medication),
-    iconColor: intake.medicine.color == null ? null : Color(intake.medicine.color!),
-  );
-
-
-}
app/lib/components/measurement_list/measurement_list.dart
@@ -1,6 +1,5 @@
 import 'package:blood_pressure_app/components/measurement_list/measurement_list_entry.dart';
 import 'package:blood_pressure_app/model/storage/settings_store.dart';
-import 'package:collection/collection.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 import 'package:health_data_store/health_data_store.dart';
@@ -32,23 +31,7 @@ class MeasurementList extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     final localizations = AppLocalizations.of(context)!;
-    final List<FullEntry> entries = [];
-    for (final r in records) {
-      final n = notes.where((n) => n.time == r.time).firstOrNull ?? Note(time: r.time);
-      final i = intakes.where((n) => n.time == r.time).toList();
-      entries.add((r, n, i));
-    }
-    Set<DateTime> times = entries.map((e) => e.time).toSet();
-    final remainingNotes = notes.where((n) => !times.contains(n.time));
-    for (final n in remainingNotes) {
-      final i = intakes.where((n) => n.time == n.time).toList();
-      entries.add((BloodPressureRecord(time: n.time), n, i));
-    }
-    times = entries.map((e) => e.time).toSet();
-    final remainingIntakes = intakes.where((i) => !times.contains(i.time));
-    for (final i in groupBy(remainingIntakes, (i) => i.time).values) {
-      entries.add((BloodPressureRecord(time: i.first.time), Note(time: i.first.time), i));
-    }
+    final entries = FullEntryList.merged(records, notes, intakes);
     return Column(
       mainAxisSize: MainAxisSize.min,
       children: [
app/lib/screens/subsettings/export_import/export_button_bar.dart
@@ -170,10 +170,7 @@ void performExport(BuildContext context, [AppLocalizations? localizations]) asyn
 
 /// Get the records that should be exported.
 Future<List<FullEntry>> _getEntries(BuildContext context) async {
-  // TODO: unify with measurement list code
-  // TODO: move function somewhere more practical
   final range = Provider.of<IntervallStoreManager>(context, listen: false).exportPage.currentRange;
-
   final bpRepo = RepositoryProvider.of<BloodPressureRepository>(context);
   final noteRepo = RepositoryProvider.of<NoteRepository>(context);
   final intakeRepo = RepositoryProvider.of<MedicineIntakeRepository>(context);
@@ -182,35 +179,7 @@ Future<List<FullEntry>> _getEntries(BuildContext context) async {
   final notes = await noteRepo.get(range);
   final intakes = await intakeRepo.get(range);
 
-  final Map<DateTime, (BloodPressureRecord?, Note?, List<MedicineIntake>)> entryMap = HashMap();
-  for (final r in records) {
-    assert(!entryMap.containsKey(r.time), 'multiple records at same time');
-    entryMap[r.time] = (r, null, []);
-  }
-  for (final n in notes) {
-    if(entryMap.containsKey(n.time)) {
-      // FIXME
-      assert(entryMap[n.time]!.$2 != null, 'multiple notes at same time');
-      entryMap[n.time] = (entryMap[n.time]!.$1, n, []);
-    } else {
-      entryMap[n.time] = (BloodPressureRecord(time: n.time), n, []);
-    }
-  }
-  for (final i in intakes) {
-    if(entryMap.containsKey(i.time)) {
-      entryMap[i.time]!.$3.add(i);
-    } else {
-      entryMap[i.time] = (null, null, [i]);
-    }
-  }
-  return entryMap
-    .values
-    .map<FullEntry>((e) {
-      // One of the values must exist or else this wouldn't be in the map.
-      final time = e.$1?.time ?? e.$2?.time ?? e.$3.firstOrNull?.time;
-      return (e.$1 ?? BloodPressureRecord(time: time!), e.$2 ?? Note(time: time!), e.$3);
-    })
-    .toList();
+  return FullEntryList.merged(records, notes, intakes);
 }
 
 /// Save to default export path or share by providing a path.
health_data_store/lib/src/types/full_entry.dart
@@ -38,7 +38,7 @@ extension FastFullEntryGetters on FullEntry {
 }
 
 /// Utility methods to work on full entries.
-extension FullEntryListUtils on List<FullEntry> {
+extension FullEntryList on List<FullEntry> {
   /// Create a list that only contains the records field from the entries.
   List<BloodPressureRecord> get records => map((e) => e.$1).toList();
 
@@ -53,4 +53,68 @@ extension FullEntryListUtils on List<FullEntry> {
     }));
     return meds.toList();
   }
+
+  /// Merges values at the same time from passed lists to FullEntries and
+  /// creates list of them.
+  ///
+  /// In the resulting list every passed value is contained exactly once. This
+  /// requires that passed [records] and [notes] contain only one entry per
+  /// timestamp.
+  static List<FullEntry> merged(
+    List<BloodPressureRecord> records,
+    List<Note> notes,
+    List<MedicineIntake> intakes,
+  ) {
+    assert(!records
+      .any((rOuter) => records
+        .where((rInner) => rOuter.time == rInner.time).length != 1,
+      ),
+      'records should only contain one entry per timestamp',
+    );
+    assert(!notes
+      .any((nOuter) => notes
+        .where((nInner) => nOuter.time == nInner.time).length != 1,
+      ),
+      'notes should only contain one entry per timestamp',
+    );
+
+    // Algorithm:
+    // 1. Create entry for every record and add notes and intakes at the same
+    //    time.
+    // 2. Determine notes that are not already in the list.
+    // 3. Create entry for these notes and add intakes at the same time.
+    // 4. Determine intakes that are not already in the list.
+    // 5. Group intakes by time.
+    // 6. Create entries for intakes at those times.
+
+    final List<FullEntry> entries = [];
+    for (final r in records) {
+      final n = notes.where((n) => n.time == r.time).firstOrNull ?? Note(time: r.time);
+      final i = intakes.where((n) => n.time == r.time).toList();
+      entries.add((r, n, i));
+    }
+
+    Set<DateTime> times = entries.map((e) => e.time).toSet();
+    final remainingNotes = notes.where((n) => !times.contains(n.time));
+
+    for (final n in remainingNotes) {
+      final i = intakes.where((i) => i.time == n.time).toList();
+      entries.add((BloodPressureRecord(time: n.time), n, i));
+    }
+
+    times = entries.map((e) => e.time).toSet();
+    final remainingIntakes = intakes.where((i) => !times.contains(i.time));
+
+    final groupedIntakes = <DateTime, List<MedicineIntake>>{};
+    for (final intake in remainingIntakes) {
+      final list = (groupedIntakes[intake.time] ??= []);
+      list.add(intake);
+    }
+
+    for (final i in groupedIntakes.values) {
+      entries.add((BloodPressureRecord(time: i.first.time), Note(time: i.first.time), i));
+    }
+
+    return entries;
+  }
 }
health_data_store/test/src/types/full_entry_test.dart
@@ -1,4 +1,5 @@
 import 'package:health_data_store/src/types/full_entry.dart';
+import 'package:health_data_store/src/types/medicine_intake.dart';
 import 'package:test/test.dart';
 
 import 'blood_pressure_record_test.dart';
@@ -104,4 +105,98 @@ void main() {
     expect(entry.recordObj, record);
     expect(entry.noteObj, note);
   });
+  test('merges lists', () {
+    final records = [
+      mockRecord(time: 10000, sys: 123, dia: 456),
+      mockRecord(time: 30000, dia: 456),
+      mockRecord(time: 40000, sys: 123, dia: 456),
+      mockRecord(time: 80000, sys: 123, dia: 456, pul: 567),
+    ];
+    final notes = [
+      mockNote(time: 10000, note: 'testnote', color: 123),
+      mockNote(time: 20000, note: 'testnote', color: 123),
+      mockNote(time: 70000, color: 123),
+    ];
+    final intakes = [
+      mockIntake(mockMedicine(), time: 10000,),
+      mockIntake(mockMedicine(), time: 20000,),
+      mockIntake(mockMedicine(), time: 30000,),
+      mockIntake(mockMedicine(), time: 50000,),
+      mockIntake(mockMedicine(), time: 50000,),
+      mockIntake(mockMedicine(), time: 60000, dosis: 12343.0),
+      mockIntake(mockMedicine(), time: 70000),
+      mockIntake(mockMedicine(), time: 70000),
+      mockIntake(mockMedicine(), time: 70000),
+    ];
+    final list = FullEntryList.merged(records, notes, intakes);
+    expect(list, hasLength(8));
+    expect(list, containsAll([
+      isA<FullEntry>()
+        .having((e) => e.time.millisecondsSinceEpoch, 'time', 10000)
+        .having((e) => e.sys?.mmHg, 'sys', 123)
+        .having((e) => e.dia?.mmHg, 'dia', 456)
+        .having((e) => e.pul, 'pul', null)
+        .having((e) => e.note, 'note', 'testnote')
+        .having((e) => e.color, 'color', 123)
+        .having((e) => e.intakes, 'intakes', hasLength(1)),
+      isA<FullEntry>()
+        .having((e) => e.time.millisecondsSinceEpoch, 'time', 20000)
+        .having((e) => e.sys?.mmHg, 'sys', null)
+        .having((e) => e.dia?.mmHg, 'dia', null)
+        .having((e) => e.pul, 'pul', null)
+        .having((e) => e.note, 'note', 'testnote')
+        .having((e) => e.color, 'color', 123)
+        .having((e) => e.intakes, 'intakes', hasLength(1)),
+      isA<FullEntry>()
+        .having((e) => e.time.millisecondsSinceEpoch, 'time', 30000)
+        .having((e) => e.sys?.mmHg, 'sys', null)
+        .having((e) => e.dia?.mmHg, 'dia', 456)
+        .having((e) => e.pul, 'pul', null)
+        .having((e) => e.note, 'note', null)
+        .having((e) => e.color, 'color', null)
+        .having((e) => e.intakes, 'intakes', hasLength(1)),
+      isA<FullEntry>()
+        .having((e) => e.time.millisecondsSinceEpoch, 'time', 40000)
+        .having((e) => e.sys?.mmHg, 'sys', 123)
+        .having((e) => e.dia?.mmHg, 'dia', 456)
+        .having((e) => e.pul, 'pul', null)
+        .having((e) => e.note, 'note', null)
+        .having((e) => e.color, 'color', null)
+        .having((e) => e.intakes, 'intakes', isEmpty),
+      isA<FullEntry>()
+        .having((e) => e.time.millisecondsSinceEpoch, 'time', 50000)
+        .having((e) => e.sys?.mmHg, 'sys', null)
+        .having((e) => e.dia?.mmHg, 'dia', null)
+        .having((e) => e.pul, 'pul', null)
+        .having((e) => e.note, 'note', null)
+        .having((e) => e.color, 'color', null)
+        .having((e) => e.intakes, 'intakes', hasLength(2)),
+      isA<FullEntry>()
+        .having((e) => e.time.millisecondsSinceEpoch, 'time', 60000)
+        .having((e) => e.sys?.mmHg, 'sys', null)
+        .having((e) => e.dia?.mmHg, 'dia', null)
+        .having((e) => e.pul, 'pul', null)
+        .having((e) => e.note, 'note', null)
+        .having((e) => e.color, 'color', null)
+        .having((e) => e.intakes, 'intakes', hasLength(1))
+        .having((e) => e.intakes, 'intakes', contains(isA<MedicineIntake>()
+          .having((i) => i.dosis.mg, 'dosis', 12343.0))),
+      isA<FullEntry>()
+        .having((e) => e.time.millisecondsSinceEpoch, 'time', 70000)
+        .having((e) => e.sys?.mmHg, 'sys', null)
+        .having((e) => e.dia?.mmHg, 'dia', null)
+        .having((e) => e.pul, 'pul', null)
+        .having((e) => e.note, 'note', null)
+        .having((e) => e.color, 'color', 123)
+        .having((e) => e.intakes, 'intakes', hasLength(3)),
+      isA<FullEntry>()
+        .having((e) => e.time.millisecondsSinceEpoch, 'time', 80000)
+        .having((e) => e.sys?.mmHg, 'sys', 123)
+        .having((e) => e.dia?.mmHg, 'dia', 456)
+        .having((e) => e.pul, 'pul', 567)
+        .having((e) => e.note, 'note', null)
+        .having((e) => e.color, 'color', null)
+        .having((e) => e.intakes, 'intakes', isEmpty),
+    ]));
+  });
 }