Commit 83740b2

derdilla <82763757+NobodyForNothing@users.noreply.github.com>
2024-09-06 15:17:11
Add weight repository to database (#426)
* respond to color changes * add body weight type * implement weight sql table * implement bodyweight repository * implement repository getters * Revert "respond to color changes" This reverts commit 52bafb05f65b94be49afdd2f83dbff23f3427364 because it was part of an unrelated PR. * fix missing export
1 parent 10fc979
health_data_store/lib/src/repositories/bodyweight_repository.dart
@@ -0,0 +1,4 @@
+import 'package:health_data_store/health_data_store.dart';
+
+/// Repository for accessing [BodyweightRecord]s.
+abstract class BodyweightRepository extends Repository<BodyweightRecord> {}
health_data_store/lib/src/repositories/bodyweight_repository_impl.dart
@@ -0,0 +1,74 @@
+import 'dart:async';
+
+import 'package:health_data_store/health_data_store.dart';
+import 'package:health_data_store/src/database_helper.dart';
+import 'package:health_data_store/src/database_manager.dart';
+import 'package:health_data_store/src/extensions/datetime_seconds.dart';
+import 'package:health_data_store/src/repositories/bodyweight_repository.dart';
+import 'package:sqflite_common/sqflite.dart';
+
+/// Implementation of repository for [BodyweightRecord]s.
+class BodyweightRepositoryImpl extends BodyweightRepository {
+  /// Create [BodyweightRecord] repository.
+  BodyweightRepositoryImpl(this._db);
+
+  final _controller = StreamController.broadcast();
+
+  /// The [DatabaseManager] managed database.
+  final Database _db;
+  
+  @override
+  Future<void> add(BodyweightRecord record) async {
+    _controller.add(null);
+    await _db.transaction((txn) async {
+      final entryID = await DBHelper.getEntryID(
+        txn,
+        record.time.secondsSinceEpoch,
+      );
+      await txn.delete('Weight', where: 'entryID = ?',
+        whereArgs: [entryID],
+      );
+      await txn.insert('Weight', {
+        'entryID': entryID,
+        'weightKg': record.weight.kg,
+      });
+    });
+  }
+
+  @override
+  Future<List<BodyweightRecord>> get(DateRange range) async {
+    final results = await _db.rawQuery(
+      'SELECT timestampUnixS, weightKg '
+        'FROM Timestamps AS t '
+        'RIGHT JOIN Weight AS w ON t.entryID = w.entryID '
+      'WHERE timestampUnixS BETWEEN ? AND ?',
+      [range.startStamp, range.endStamp]
+    );
+    return <BodyweightRecord>[
+      for (final r in results)
+        BodyweightRecord(
+          time: DateTimeS.fromSecondsSinceEpoch(r['timestampUnixS'] as int),
+          weight: Weight.kg(r['weightKg'] as double)
+        ),
+    ];
+  }
+
+  @override
+  Future<void> remove(BodyweightRecord record) async {
+    _controller.add(null);
+    await _db.rawDelete(
+      'DELETE FROM Weight WHERE entryID IN ('
+        'SELECT entryID FROM Timestamps '
+        'WHERE timestampUnixS = ?'
+      ') AND weightKg = ?',
+      [
+        record.time.secondsSinceEpoch,
+        record.weight.kg,
+      ],
+    );
+  }
+
+  @override
+  Stream subscribe() => _controller.stream;
+
+}
health_data_store/lib/src/types/units/weight.dart
@@ -3,6 +3,12 @@ class Weight {
   /// Create a weight from milligrams.
   Weight.mg(this._value);
 
+  /// Create a weight from grams.
+  Weight.g(double value): _value = value * 1000;
+
+  /// Create a weight from kilograms.
+  Weight.kg(double value): _value = value * 1000 * 1000;
+
   /// Create a weight from [grain](https://en.wikipedia.org/wiki/Grain_(unit)).
   Weight.gr(double value): _value = value * 64.79891;
 
@@ -12,6 +18,12 @@ class Weight {
   /// The weight in milligrams.
   double get mg => _value;
 
+  /// The weight in grams.
+  double get g => _value / 1000;
+
+  /// The weight in kilograms.
+  double get kg => _value / 1000000;
+
   /// Get the value in [grain](https://en.wikipedia.org/wiki/Grain_(unit)).
   double get gr => mg / 64.79891;
 
health_data_store/lib/src/types/bodyweight_record.dart
@@ -0,0 +1,16 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'package:health_data_store/src/types/units/weight.dart';
+
+part 'bodyweight_record.freezed.dart';
+
+/// Body weight at a specific time.
+@freezed
+class BodyweightRecord with _$BodyweightRecord {
+  /// Create a body weight measurement.
+  const factory BodyweightRecord({
+    /// Timestamp when the weight was measured.
+    required DateTime time,
+    /// Weight at [time].
+    required Weight weight,
+  }) = _BodyweightRecord;
+}
health_data_store/lib/src/database_manager.dart
@@ -38,7 +38,10 @@ class DatabaseManager {
 
     if (!isReadOnly && await dbMngr._db.getVersion() < 3) {
       await dbMngr._setUpTables();
-      await dbMngr._db.setVersion(3);
+      await dbMngr._db.setVersion(4);
+    } else if (!isReadOnly && await dbMngr._db.getVersion() == 3) {
+      await dbMngr._setupWeightTable(dbMngr._db);
+      await dbMngr._db.setVersion(4);
     }
     // When updating the schema the update steps are maintained for ensured 
     // compatability.
@@ -97,8 +100,18 @@ class DatabaseManager {
       'FOREIGN KEY("entryID") REFERENCES "Timestamps"("entryID"),'
       'PRIMARY KEY("entryID")'
     ');');
+    await _setupWeightTable(txn);
   });
 
+  Future<void> _setupWeightTable(DatabaseExecutor executor) async {
+    await executor.execute('CREATE TABLE "Weight" ('
+      '"entryID"	    INTEGER NOT NULL,'
+      '"weightKg"     REAL NOT NULL,'
+      'FOREIGN KEY("entryID") REFERENCES "Timestamps"("entryID"),'
+      'PRIMARY KEY("entryID")'
+    ');');
+  }
+
   /// Removes unused and deleted entries rows.
   ///
   /// Specifically:
@@ -116,6 +129,7 @@ class DatabaseManager {
       'AND entryID NOT IN (SELECT entryID FROM Systolic) '
       'AND entryID NOT IN (SELECT entryID FROM Diastolic) '
       'AND entryID NOT IN (SELECT entryID FROM Pulse) '
+      'AND entryID NOT IN (SELECT entryID FROM Weight) '
       'AND entryID NOT IN (SELECT entryID FROM Notes);',
     );
   });
health_data_store/lib/src/health_data_store.dart
@@ -3,6 +3,8 @@ import 'dart:async';
 import 'package:health_data_store/src/database_manager.dart';
 import 'package:health_data_store/src/repositories/blood_pressure_repository.dart';
 import 'package:health_data_store/src/repositories/blood_pressure_repository_impl.dart';
+import 'package:health_data_store/src/repositories/bodyweight_repository.dart';
+import 'package:health_data_store/src/repositories/bodyweight_repository_impl.dart';
 import 'package:health_data_store/src/repositories/medicine_intake_repository.dart';
 import 'package:health_data_store/src/repositories/medicine_intake_repository_impl.dart';
 import 'package:health_data_store/src/repositories/medicine_repository.dart';
@@ -60,4 +62,8 @@ class HealthDataStore {
   /// Repository for intakes.
   MedicineIntakeRepository get intakeRepo =>
     MedicineIntakeRepositoryImpl(_dbMngr.db);
+
+  /// Repository for weight data.
+  BodyweightRepository get weightRepo =>
+    BodyweightRepositoryImpl(_dbMngr.db);
 }
health_data_store/lib/health_data_store.dart
@@ -27,12 +27,14 @@ library;
 export 'src/health_data_store.dart';
 // repositories
 export 'src/repositories/blood_pressure_repository.dart';
+export 'src/repositories/bodyweight_repository.dart';
 export 'src/repositories/medicine_intake_repository.dart';
 export 'src/repositories/medicine_repository.dart';
 export 'src/repositories/note_repository.dart';
 export 'src/repositories/repository.dart';
 // types
 export 'src/types/blood_pressure_record.dart';
+export 'src/types/bodyweight_record.dart';
 export 'src/types/date_range.dart';
 export 'src/types/full_entry.dart';
 export 'src/types/medicine.dart';
health_data_store/test/src/repositories/bodyweight_repository_test.dart
@@ -0,0 +1,114 @@
+import 'dart:async';
+
+import 'package:health_data_store/src/repositories/bodyweight_repository_impl.dart';
+import 'package:health_data_store/src/types/date_range.dart';
+import 'package:test/test.dart';
+
+import '../database_manager_test.dart';
+import '../types/bodyweight_record_test.dart';
+
+void main() {
+  sqfliteTestInit();
+  test('should initialize', () async {
+    final db = await mockDBManager();
+    addTearDown(db.close);
+    BodyweightRepositoryImpl(db.db);
+  });
+  test('returns stored records', () async {
+    final db = await mockDBManager();
+    addTearDown(db.close);
+    final repo = BodyweightRepositoryImpl(db.db);
+    final r1 = mockWeight(time: 123456000, kg: 123.456);
+    final r2 = mockWeight(time: 1234567000, kg: 0.456);
+    await repo.add(r1);
+    await repo.add(r2);
+
+    final values = await repo.get(DateRange(
+      start: DateTime.fromMillisecondsSinceEpoch(123450000),
+      end: DateTime.fromMillisecondsSinceEpoch(1234570000),
+    ));
+    expect(values, hasLength(2));
+    expect(values, containsAll([r1,r2]));
+  });
+  test('removes records', () async {
+    final db = await mockDBManager();
+    addTearDown(db.close);
+    final repo = BodyweightRepositoryImpl(db.db);
+    final r1 = mockWeight(time: 123456000, kg: 123.456);
+
+    await repo.add(r1);
+    final values1 = await repo.get(DateRange(
+      start: DateTime.fromMillisecondsSinceEpoch(123450000),
+      end: DateTime.fromMillisecondsSinceEpoch(1234570000),
+    ));
+    expect(values1, hasLength(1));
+
+    await repo.remove(r1);
+    final values2 = await repo.get(DateRange(
+      start: DateTime.fromMillisecondsSinceEpoch(123450000),
+      end: DateTime.fromMillisecondsSinceEpoch(1234570000),
+    ));
+    expect(values2, isEmpty);
+  });
+  test('overrides when inserting multiple records are at same time', () async {
+    final db = await mockDBManager();
+    addTearDown(db.close);
+    final repo = BodyweightRepositoryImpl(db.db);
+    final r1 = mockWeight(time: 123456000, kg: 1.0);
+    final r2 = mockWeight(time: 123456000, kg: 2.0);
+    await repo.add(r1);
+    await repo.add(r2);
+
+    final values = await repo.get(DateRange(
+      start: DateTime.fromMillisecondsSinceEpoch(123450000),
+      end: DateTime.fromMillisecondsSinceEpoch(1234570000),
+    ));
+    expect(values, hasLength(1));
+    expect(values, containsAll([r2]));
+  });
+  test("doesn't throw when removing non existent record", () async {
+    final db = await mockDBManager();
+    addTearDown(db.close);
+    final repo = BodyweightRepositoryImpl(db.db);
+    final r1 = mockWeight(time: 123456000, kg: 1.0);
+
+    await repo.remove(r1);
+    final values = await repo.get(DateRange(
+      start: DateTime.fromMillisecondsSinceEpoch(0),
+      end: DateTime.fromMillisecondsSinceEpoch(123457000),
+    ));
+    expect(values, isEmpty);
+  });
+  test("doesn't return records out of range", () async {
+    final db = await mockDBManager();
+    addTearDown(db.close);
+    final repo = BodyweightRepositoryImpl(db.db);
+    final r1 = mockWeight(time: 10000, kg: 1.0);
+    await repo.add(r1);
+
+    final values = await repo.get(DateRange(
+      start: DateTime.fromMillisecondsSinceEpoch(20000),
+      end: DateTime.fromMillisecondsSinceEpoch(80000),
+    ));
+    expect(values, isEmpty);
+  });
+  test('emits stream events on changes', () async {
+    final db = await mockDBManager();
+    addTearDown(db.close);
+    int notifyCount = 0;
+
+    final repo = BodyweightRepositoryImpl(db.db);
+    unawaited(repo.subscribe().forEach((_) => notifyCount++));
+    final r = mockWeight(time: 10000, kg: 1.0);
+    expect(notifyCount, 0);
+
+    await repo.add(r);
+    expect(notifyCount, 1);
+
+    await repo.add(mockWeight(time: 20000, kg: 2.0));
+    expect(notifyCount, 2);
+
+    await repo.remove(r);
+    expect(notifyCount, 3);
+  }, timeout: const Timeout(Duration(seconds: 5)));
+}
health_data_store/test/src/types/units/weight_test.dart
@@ -0,0 +1,15 @@
+import 'package:health_data_store/health_data_store.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('returns the same value as constructed with', () {
+    expect(Weight.mg(1234.45).mg, 1234.45);
+    expect(Weight.g(1234.45).g,   1234.45);
+    expect(Weight.kg(1234.45).kg, 1234.45);
+    expect(Weight.gr(1234.45).gr, 1234.45);
+  });
+
+  test('equal across constructor typed', () {
+    expect(Weight.mg(1000), Weight.g(1));
+  });
+}
health_data_store/test/src/types/bodyweight_record_test.dart
@@ -0,0 +1,21 @@
+import 'package:health_data_store/src/types/bodyweight_record.dart';
+import 'package:health_data_store/src/types/units/weight.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('should initialize', () {
+    final weight = BodyweightRecord(
+      time: DateTime.now(),
+      weight: Weight.kg(60),
+    );
+    expect(weight.weight, Weight.kg(60));
+  });
+}
+
+BodyweightRecord mockWeight({
+  int? time,
+  double? kg,
+}) => BodyweightRecord(
+  time: time!=null ? DateTime.fromMillisecondsSinceEpoch(time) : DateTime.now(),
+  weight: Weight.kg(kg ?? 42.0),
+);
health_data_store/test/src/database_manager_test.dart
@@ -86,7 +86,7 @@ void main() {
     await expectLater(() async => db.db.insert('medicine', item4),
         throwsException);
   });
-  test('should create timestamps table correctly', () async {
+  test('creates timestamps table correctly', () async {
     final db = await mockDBManager();
     addTearDown(db.close);
     await db.db.insert('Timestamps', {
@@ -106,7 +106,7 @@ void main() {
       'timestampUnixS': null,
     }), throwsException);
   });
-  test('should create intake table correctly', () async {
+  test('creates intake table correctly', () async {
     final db = await mockDBManager();
     addTearDown(db.close);
 
@@ -119,7 +119,7 @@ void main() {
     expect(data, hasLength(1));
     expect(data.first.keys, hasLength(3));
   });
-  test('should create timestamps sys,dia,pul tables correctly', () async {
+  test('creates timestamps sys,dia,pul tables correctly', () async {
     final db = await mockDBManager();
     addTearDown(db.close);
     for (final t in [
@@ -140,7 +140,7 @@ void main() {
       expect(data.first.keys, hasLength(2));
     }
   });
-  test('should create notes table correctly', () async {
+  test('creates notes table correctly', () async {
     final db = await mockDBManager();
     addTearDown(db.close);
 
@@ -154,6 +154,19 @@ void main() {
     expect(data.first.keys, hasLength(3));
     expect(data.first['color'], equals(0xFF990098));
   });
+  test('creates weight table correctly', () async {
+    final db = await mockDBManager();
+    addTearDown(db.close);
+
+    await db.db.insert('Weight', {
+      'entryID': 2,
+      'weightKg': 123.45,
+    });
+    final data = await db.db.query('Weight');
+    expect(data, hasLength(1));
+    expect(data.first.keys, hasLength(2));
+    expect(data.first['weightKg'], equals(123.45));
+  });
   test('should cleanup unused timestamps', () async {
     final db = await mockDBManager();
     addTearDown(db.close);
@@ -198,7 +211,7 @@ void main() {
     final db = await mockDBManager();
     addTearDown(db.close);
 
-    for (int i = 1; i <= 6; i += 1) {
+    for (int i = 1; i <= 7; i += 1) {
       await db.db.insert('Timestamps', {
         'entryID': i,
         'timestampUnixS': i,
@@ -213,10 +226,12 @@ void main() {
     await db.db.insert('Diastolic', {'entryID': 3,});
     await db.db.insert('Pulse', {'entryID': 4,});
     await db.db.insert('Notes', {'entryID': 5,});
+    await db.db.insert('Weight', {'entryID': 6, 'weightKg': 1.0});
 
-    expect(await db.db.query('Timestamps'), hasLength(6));
+    expect(await db.db.query('Timestamps'), hasLength(7));
     await db.performCleanup();
-    expect(await db.db.query('Timestamps'), hasLength(5)); // remove 6 keep rest
+    expect(await db.db.query('Timestamps'), hasLength(6));
+    // only one isn't used
   });
 }
 
health_data_store/test/src/health_data_store_test.dart
@@ -21,6 +21,7 @@ void main() {
   expect(() => store.intakeRepo, returnsNormally);
   expect(() => store.bpRepo, returnsNormally);
   expect(() => store.noteRepo, returnsNormally);
+  expect(() => store.weightRepo, returnsNormally);
  });
  test('constructed repos should work', () async {
   final store = await HealthDataStore.load(