Commit 83740b2
Changed files (12)
health_data_store
lib
test
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(