Commit a187ea6

derdilla <82763757+NobodyForNothing@users.noreply.github.com>
2023-12-26 20:48:00
model medicine storage
Signed-off-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com>
1 parent 560e476
Changed files (4)
lib
test
lib/model/blood_pressure/medicine/intake_history.dart
@@ -0,0 +1,106 @@
+import 'package:blood_pressure_app/model/blood_pressure/medicine/medicine_intake.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter/material.dart';
+
+/// Model of all medicine intakes that allows fast access of data.
+///
+/// Internally maintains a sorted list of intakes to allow for binary search.
+class IntakeHistory {
+
+  IntakeHistory(List<MedicineIntake> medicineIntakes):
+    _medicineIntakes = medicineIntakes.sorted((p0, p1) => p0.compareTo(p1));
+
+  /// List of all medicine intakes sorted in ascending order.
+  ///
+  /// Can contain multiple medicine intakes at the same time.
+  final List<MedicineIntake> _medicineIntakes;
+
+
+  /// Returns all intakes in a given range in ascending order.
+  ///
+  /// Binary searches the lower and the upper bound of stored intakes to create
+  /// the list view.
+  UnmodifiableListView<MedicineIntake> getIntakes(DateTimeRange range) {
+    if (_medicineIntakes.isEmpty) return UnmodifiableListView([]);
+    int start = _findLowerBound(_medicineIntakes, range.start);
+    int end = _findUpperBound(_medicineIntakes, range.end);
+
+    if (start < 0) start = 0;
+    assert(end < _medicineIntakes.length);
+    if (end < 0) end = _medicineIntakes.length;
+
+    return UnmodifiableListView(_medicineIntakes.getRange(start, end));
+  }
+
+  /// Use binary search to determine the first index in [list] before which all
+  /// values that are before or at the same time as [t].
+  int _findUpperBound(List<MedicineIntake> list, DateTime t) {
+    int low = 0;
+    int high = list.length - 1;
+
+    int idx = -1;
+    while (low <= high) {
+      final int mid = low + ((high - low) >> 1);
+      assert (mid == (low + (high - low) / 2).toInt());
+
+      if (list[mid].timestamp.isBefore(t) || list[mid].timestamp.isAtSameMomentAs(t)) {
+        low = mid + 1;
+      } else {
+        idx = mid;
+        high = mid - 1;
+      }
+    }
+
+    return idx;
+  }
+
+  /// Use binary search to determine the last index in [list] after which before
+  /// all values that are after or at the same time as [t].
+  int _findLowerBound(List<MedicineIntake> list, DateTime t) {
+    int low = 0;
+    int high = list.length - 1;
+
+    int idx = -1;
+    while (low <= high) {
+      final int mid = low + ((high - low) >> 1);
+      assert (mid == (low + (high - low) / 2).toInt());
+
+      if (list[mid].timestamp.isAfter(t) || list[mid].timestamp.isAtSameMomentAs(t) ){
+        high = mid - 1;
+      } else {
+        idx = mid;
+        low = mid + 1;
+      }
+    }
+
+    return idx + 1;
+  }
+
+  /// Save a medicine intake.
+  ///
+  /// Inserts the intake at the upper bound of intakes that are bigger or equal.
+  /// When no smaller bigger intake is available insert to the end of the list.
+  ///
+  /// Uses binary search to determine the bound.
+  void addIntake(MedicineIntake intake) {
+    int index = _findUpperBound(_medicineIntakes, intake.timestamp);
+
+    if (index == -1) {
+      _medicineIntakes.add(intake);
+    } else {
+      _medicineIntakes.insert(index, intake);
+    }
+  }
+
+  /// Attempts to delete a medicine intake.
+  ///
+  /// When finding multiple intakes with the same timestamp, medicine
+  /// and dosis all instances will get deleted.
+  void deleteIntake(MedicineIntake intake) {
+    int idx = binarySearch(_medicineIntakes, intake);
+    while (idx >= 0) {
+      _medicineIntakes.removeAt(idx);
+      idx = binarySearch(_medicineIntakes, intake);
+    }
+  }
+}
\ No newline at end of file
lib/model/blood_pressure/medicine/medicine.dart
@@ -0,0 +1,35 @@
+import 'dart:ui';
+
+import 'package:blood_pressure_app/model/blood_pressure/medicine/medicine_intake.dart';
+import 'package:flutter/material.dart';
+
+/// Description of a specific medicine.
+class Medicine {
+  /// Create a new medicine.
+  const Medicine({
+    required this.designation,
+    required this.color,
+    required this.defaultDosis
+  });
+
+  /// Name of the medicine.
+  final String designation;
+
+  /// Color used to display medicine intake
+  final Color color;
+
+  /// Default dosis used to autofill [MedicineIntake].
+  final double? defaultDosis;
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is Medicine &&
+          runtimeType == other.runtimeType &&
+          designation == other.designation &&
+          color == other.color &&
+          defaultDosis == other.defaultDosis;
+
+  @override
+  int get hashCode => designation.hashCode ^ color.hashCode ^ defaultDosis.hashCode;
+}
lib/model/blood_pressure/medicine/medicine_intake.dart
@@ -0,0 +1,44 @@
+
+import 'package:blood_pressure_app/model/blood_pressure/medicine/medicine.dart';
+
+/// Instance of a medicine intake.
+class MedicineIntake implements Comparable<Object> {
+  /// Create a instance of a medicine intake.
+  const MedicineIntake({
+    required this.medicine,
+    required this.dosis,
+    required this.timestamp
+  });
+
+  /// Kind of medicine taken.
+  final Medicine medicine;
+
+  /// Amount in mg of medicine taken.
+  final double dosis;
+
+  /// Time when the medicine was taken.
+  final DateTime timestamp;
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is MedicineIntake &&
+          runtimeType == other.runtimeType &&
+          medicine == other.medicine &&
+          dosis == other.dosis &&
+          timestamp == other.timestamp;
+
+  @override
+  int get hashCode => medicine.hashCode ^ dosis.hashCode ^ timestamp.hashCode;
+
+  @override
+  int compareTo(other) {
+    assert(other is MedicineIntake);
+    if (other is! MedicineIntake) return 0;
+
+    final timeCompare = timestamp.compareTo(other.timestamp);
+    if (timeCompare != 0) return timeCompare;
+
+    return dosis.compareTo(other.dosis);
+  }
+}
\ No newline at end of file
test/model/medicine/intake_history_test.dart
@@ -0,0 +1,173 @@
+import 'package:blood_pressure_app/model/blood_pressure/medicine/intake_history.dart';
+import 'package:blood_pressure_app/model/blood_pressure/medicine/medicine.dart';
+import 'package:blood_pressure_app/model/blood_pressure/medicine/medicine_intake.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  group('IntakeHistory', () { 
+    test('should return all matching intakes in range', () {
+      final history = IntakeHistory([
+        mockIntake(timeMs: 2),
+        mockIntake(timeMs: 2),
+        mockIntake(timeMs: 4),
+        mockIntake(timeMs: 5),
+        mockIntake(timeMs: 6),
+        mockIntake(timeMs: 9),
+        mockIntake(timeMs: 9),
+        mockIntake(timeMs: 12),
+        mockIntake(timeMs: 15),
+        mockIntake(timeMs: 15),
+        mockIntake(timeMs: 16),
+        mockIntake(timeMs: 17),
+      ]);
+      final found = history.getIntakes(DateTimeRange(
+        start: DateTime.fromMillisecondsSinceEpoch(4), 
+        end: DateTime.fromMillisecondsSinceEpoch(15)
+      ));
+      expect(found.length, 8);
+      expect(found.map((e) => e.timestamp.millisecondsSinceEpoch), containsAllInOrder([4,5,6,9,9,12,15,15]));
+    });
+    test('should return all matching intakes when only few are in range', () {
+      final history = IntakeHistory([
+        mockIntake(timeMs: 2),
+        mockIntake(timeMs: 3),
+      ]);
+      final found = history.getIntakes(DateTimeRange(
+          start: DateTime.fromMillisecondsSinceEpoch(0),
+          end: DateTime.fromMillisecondsSinceEpoch(4)
+      ));
+      expect(found.length, 2);
+      expect(found.map((e) => e.timestamp.millisecondsSinceEpoch), containsAllInOrder([2,3]));
+    });
+    test('should return nothing when no intakes are present', () {
+      final history = IntakeHistory([]);
+      final found = history.getIntakes(DateTimeRange(
+          start: DateTime.fromMillisecondsSinceEpoch(0),
+          end: DateTime.fromMillisecondsSinceEpoch(1000)
+      ));
+      expect(found.length, 0);
+    });
+    test('should return nothing when intakes are out of range', () {
+      final history = IntakeHistory([
+        mockIntake(timeMs: 2),
+        mockIntake(timeMs: 3),
+      ]);
+      final found1 = history.getIntakes(DateTimeRange(
+          start: DateTime.fromMillisecondsSinceEpoch(4),
+          end: DateTime.fromMillisecondsSinceEpoch(10)
+      ));
+      expect(found1.length, 0);
+      final found2 = history.getIntakes(DateTimeRange(
+          start: DateTime.fromMillisecondsSinceEpoch(0),
+          end: DateTime.fromMillisecondsSinceEpoch(1)
+      ));
+      expect(found2.length, 0);
+    });
+    test('should add to the correct position', () {
+      final history = IntakeHistory([
+        mockIntake(timeMs: 2),
+        mockIntake(timeMs: 7),
+      ]);
+
+      history.addIntake(mockIntake(timeMs: 3));
+      final found = history.getIntakes(DateTimeRange(
+          start: DateTime.fromMillisecondsSinceEpoch(0),
+          end: DateTime.fromMillisecondsSinceEpoch(10)
+      ));
+      expect(found.length, 3);
+      expect(found.map((e) => e.timestamp.millisecondsSinceEpoch), containsAllInOrder([2,3,7]));
+
+      history.addIntake(mockIntake(timeMs: 3));
+      final found2 = history.getIntakes(DateTimeRange(
+          start: DateTime.fromMillisecondsSinceEpoch(0),
+          end: DateTime.fromMillisecondsSinceEpoch(10)
+      ));
+      expect(found2.length, 4);
+      expect(found2.map((e) => e.timestamp.millisecondsSinceEpoch), containsAllInOrder([2,3,3,7]));
+
+      history.addIntake(mockIntake(timeMs: 1));
+      final found3 = history.getIntakes(DateTimeRange(
+          start: DateTime.fromMillisecondsSinceEpoch(0),
+          end: DateTime.fromMillisecondsSinceEpoch(10)
+      ));
+      expect(found3.length, 5);
+      expect(found3.map((e) => e.timestamp.millisecondsSinceEpoch), containsAllInOrder([1,2,3,3,7]));
+
+      history.addIntake(mockIntake(timeMs: 10));
+      final found4 = history.getIntakes(DateTimeRange(
+          start: DateTime.fromMillisecondsSinceEpoch(0),
+          end: DateTime.fromMillisecondsSinceEpoch(10)
+      ));
+      expect(found4.length, 6);
+      expect(found4.map((e) => e.timestamp.millisecondsSinceEpoch), containsAllInOrder([1,2,3,3,7,10]));
+    });
+    test('should remove deleted intakes', () {
+      final history = IntakeHistory([
+        mockIntake(timeMs: 2),
+        mockIntake(timeMs: 2),
+        mockIntake(timeMs: 4),
+        mockIntake(timeMs: 5),
+        mockIntake(timeMs: 6),
+        mockIntake(timeMs: 9),
+        mockIntake(timeMs: 9, dosis: 2),
+        mockIntake(timeMs: 12),
+      ]);
+      history.deleteIntake(mockIntake(timeMs: 5));
+      final found = history.getIntakes(DateTimeRange(
+          start: DateTime.fromMillisecondsSinceEpoch(0),
+          end: DateTime.fromMillisecondsSinceEpoch(20)
+      ));
+      expect(found.length, 7);
+      expect(found.map((e) => e.timestamp.millisecondsSinceEpoch),
+          containsAllInOrder([2,2,4,6,9,9,12]));
+
+      history.deleteIntake(mockIntake(timeMs: 9));
+      final found3 = history.getIntakes(DateTimeRange(
+          start: DateTime.fromMillisecondsSinceEpoch(0),
+          end: DateTime.fromMillisecondsSinceEpoch(20)
+      ));
+      expect(found3.length, 6);
+      expect(found3.map((e) => e.timestamp.millisecondsSinceEpoch),
+          containsAllInOrder([2,2,4,6,9,12]));
+
+      history.deleteIntake(mockIntake(timeMs: 2));
+      final found4 = history.getIntakes(DateTimeRange(
+          start: DateTime.fromMillisecondsSinceEpoch(0),
+          end: DateTime.fromMillisecondsSinceEpoch(20)
+      ));
+      expect(found4.length, 4);
+      expect(found4.map((e) => e.timestamp.millisecondsSinceEpoch),
+          containsAllInOrder([4,6,9,12]));
+    });
+    test('should not fail on deleting non existent intake', () {
+      final history = IntakeHistory([]);
+      history.deleteIntake(mockIntake(timeMs: 5));
+      final found = history.getIntakes(DateTimeRange(
+          start: DateTime.fromMillisecondsSinceEpoch(0),
+          end: DateTime.fromMillisecondsSinceEpoch(20)
+      ));
+      expect(found.length, 0);
+    });
+  });
+}
+
+/// Create a mock intake.
+/// 
+/// [timeMs] creates the intake timestamp through [DateTime.fromMillisecondsSinceEpoch].
+/// When is null [DateTime.now] is used.
+MedicineIntake mockIntake({
+  Color medicineColor = Colors.black,
+  String medicineDesignation = '',
+  double? medicineDefaultDosis,
+  double dosis = 0,
+  int? timeMs
+}) => MedicineIntake(
+  medicine: Medicine(
+    color: medicineColor, 
+    designation: medicineDesignation,
+    defaultDosis: medicineDefaultDosis
+  ), 
+  dosis: dosis, 
+  timestamp: timeMs == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(timeMs)
+);
\ No newline at end of file