Commit a187ea6
Changed files (4)
lib
model
blood_pressure
test
model
medicine
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