diff --git a/openhealth/ios/Podfile.lock b/openhealth/ios/Podfile.lock index 24396d6..e07d164 100644 --- a/openhealth/ios/Podfile.lock +++ b/openhealth/ios/Podfile.lock @@ -6,11 +6,15 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS DEPENDENCIES: - Flutter (from `Flutter`) - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) EXTERNAL SOURCES: Flutter: @@ -19,11 +23,14 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/openhealth/lib/src/persistence/health_store.dart b/openhealth/lib/src/persistence/health_store.dart new file mode 100644 index 0000000..0e1d75b --- /dev/null +++ b/openhealth/lib/src/persistence/health_store.dart @@ -0,0 +1,25 @@ +import 'package:cgm_core/cgm_core.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'sqflite_health_repository.dart'; + +export 'sqflite_health_repository.dart'; + +/// Default on-disk filename for the local health database. +const String kHealthDbFileName = 'openhealth_health.db'; + +/// Opens the app's local-first [HealthRepository], backed by sqflite in the +/// platform application-documents directory. +/// +/// This is the production entry point the journaling / AI / HealthKit-import +/// features should use. Tests construct [SqfliteHealthRepository] directly with +/// an in-memory FFI factory instead, so they need no device or file system. +Future openHealthRepository({ + String fileName = kHealthDbFileName, +}) async { + final dir = await getApplicationDocumentsDirectory(); + final repo = SqfliteHealthRepository(path: p.join(dir.path, fileName)); + await repo.init(); + return repo; +} diff --git a/openhealth/lib/src/persistence/sqflite_health_repository.dart b/openhealth/lib/src/persistence/sqflite_health_repository.dart new file mode 100644 index 0000000..63133fc --- /dev/null +++ b/openhealth/lib/src/persistence/sqflite_health_repository.dart @@ -0,0 +1,445 @@ +import 'dart:convert'; + +import 'package:cgm_core/cgm_core.dart'; +import 'package:sqflite/sqflite.dart'; + +/// On-device, local-first [HealthRepository] backed by SQLite via `sqflite`. +/// +/// Why sqflite (vs. hive/isar): the repository's core access pattern is +/// "give me records of type X in time window [a, b)". A relational store +/// indexes those columns and answers such queries in SQL rather than scanning +/// every record in Dart, and it scales comfortably to the many activity / +/// heart-rate rows a HealthKit import produces. sqflite is the most mature, +/// lowest-risk idiomatic on-device store for Flutter and exposes the +/// schema-version migration hooks ([onCreate]/[onUpgrade]) this layer needs. +/// +/// Everything stays on the device — there is no cloud sync. CGM reading +/// history keeps its existing `shared_preferences` persistence and is out of +/// scope here. +/// +/// Storage model: domain objects are stored as their JSON map (the same +/// `toJson`/`fromJson` the models already define) in a `data` TEXT column, with +/// the fields used for filtering (timestamps as epoch-millis, type/category +/// keys) promoted to dedicated, indexed columns. This keeps schema churn low as +/// models gain fields while keeping window/type queries index-backed. +class SqfliteHealthRepository implements HealthRepository { + SqfliteHealthRepository({ + required String path, + DatabaseFactory? databaseFactory, + }) : _path = path, + // Defaults to the on-device sqflite plugin factory; tests inject + // `databaseFactoryFfi` for an on-host, in-memory database. + _databaseFactory = databaseFactory ?? databaseFactorySqflitePlugin; + + /// Current schema version. Bump and extend [_migrate] for changes. + static const int schemaVersion = 1; + + static const String tableEvents = 'health_events'; + static const String tableActivity = 'activity_samples'; + static const String tableSleep = 'sleep_samples'; + static const String tableHeartRate = 'heart_rate_samples'; + static const String tableInsights = 'ai_insights'; + + final String _path; + final DatabaseFactory _databaseFactory; + Database? _db; + + Database get _database { + final db = _db; + if (db == null) { + throw StateError('SqfliteHealthRepository.init() must be called first.'); + } + return db; + } + + @override + Future init() async { + if (_db != null) return; + _db = await _databaseFactory.openDatabase( + _path, + options: OpenDatabaseOptions( + version: schemaVersion, + onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'), + onCreate: (db, version) async { + // Create at v0 then run forward migrations so onCreate and onUpgrade + // share one source of truth. + await _migrate(db, 0, version); + }, + onUpgrade: _migrate, + ), + ); + } + + @override + Future close() async { + await _db?.close(); + _db = null; + } + + /// Forward migrations from [from] (exclusive) to [to] (inclusive). + /// + /// Each `if (from < N)` block upgrades the schema to version N, so the chain + /// runs cleanly whether a fresh install jumps 0 -> latest or an existing + /// install steps one version at a time. + static Future _migrate(Database db, int from, int to) async { + if (from < 1) { + await db.execute(''' + CREATE TABLE $tableEvents ( + id TEXT PRIMARY KEY, + timestamp_ms INTEGER NOT NULL, + type TEXT NOT NULL, + data TEXT NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX idx_events_ts ON $tableEvents(timestamp_ms)', + ); + await db.execute('CREATE INDEX idx_events_type ON $tableEvents(type)'); + + await db.execute(''' + CREATE TABLE $tableActivity ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ms INTEGER NOT NULL, + type TEXT NOT NULL, + data TEXT NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX idx_activity_start ON $tableActivity(start_ms)', + ); + await db.execute( + 'CREATE INDEX idx_activity_type ON $tableActivity(type)', + ); + + await db.execute(''' + CREATE TABLE $tableSleep ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ms INTEGER NOT NULL, + data TEXT NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX idx_sleep_start ON $tableSleep(start_ms)', + ); + + await db.execute(''' + CREATE TABLE $tableHeartRate ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp_ms INTEGER NOT NULL, + data TEXT NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX idx_hr_ts ON $tableHeartRate(timestamp_ms)', + ); + + await db.execute(''' + CREATE TABLE $tableInsights ( + id TEXT PRIMARY KEY, + created_ms INTEGER NOT NULL, + category TEXT NOT NULL, + data TEXT NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX idx_insights_created ON $tableInsights(created_ms)', + ); + await db.execute( + 'CREATE INDEX idx_insights_category ON $tableInsights(category)', + ); + } + // Future migrations: if (from < 2) { ... } // bump [schemaVersion] too. + } + + static int _ms(DateTime t) => t.toUtc().millisecondsSinceEpoch; + + /// Builds a `WHERE` clause + args for [column] within [window]. + static (String, List) _windowClause( + String column, + TimeWindow window, + ) { + final clauses = []; + final args = []; + if (window.start != null) { + clauses.add('$column >= ?'); + args.add(_ms(window.start!)); + } + if (window.end != null) { + clauses.add('$column < ?'); + args.add(_ms(window.end!)); + } + return (clauses.isEmpty ? '' : clauses.join(' AND '), args); + } + + /// Combines a window clause with an optional `IN (...)` key filter. + static (String?, List) _whereWith( + String timeColumn, + TimeWindow window, + String? keyColumn, + Iterable? keys, + ) { + final (timeClause, args) = _windowClause(timeColumn, window); + final parts = [if (timeClause.isNotEmpty) timeClause]; + if (keyColumn != null && keys != null) { + final list = keys.toList(); + if (list.isEmpty) { + // Empty filter set => match nothing. + return ('0 = 1', const []); + } + parts.add('$keyColumn IN (${List.filled(list.length, '?').join(', ')})'); + args.addAll(list); + } + return (parts.isEmpty ? null : parts.join(' AND '), args); + } + + // --- Health events ------------------------------------------------------- + + @override + Future upsertEvent(HealthEvent event) => upsertEvents([event]); + + @override + Future upsertEvents(Iterable events) async { + final batch = _database.batch(); + for (final e in events) { + batch.insert(tableEvents, { + 'id': e.id, + 'timestamp_ms': _ms(e.timestamp), + 'type': e.type.key, + 'data': jsonEncode(e.toJson()), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + + @override + Future deleteEvent(String id) async { + await _database.delete(tableEvents, where: 'id = ?', whereArgs: [id]); + } + + @override + Future getEvent(String id) async { + final rows = await _database.query( + tableEvents, + columns: ['data'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) return null; + return HealthEvent.fromJson(_decode(rows.first['data'])); + } + + @override + Future> queryEvents({ + TimeWindow window = TimeWindow.all, + Set? types, + }) async { + final (where, args) = _whereWith( + 'timestamp_ms', + window, + 'type', + types?.map((t) => t.key), + ); + final rows = await _database.query( + tableEvents, + columns: ['data'], + where: where, + whereArgs: args, + orderBy: 'timestamp_ms ASC', + ); + return rows + .map((r) => HealthEvent.fromJson(_decode(r['data']))) + .toList(growable: false); + } + + // --- Activity samples ---------------------------------------------------- + + @override + Future upsertActivitySamples(Iterable samples) async { + final batch = _database.batch(); + for (final s in samples) { + batch.insert(tableActivity, { + 'start_ms': _ms(s.start), + 'type': s.type.key, + 'data': jsonEncode(s.toJson()), + }); + } + await batch.commit(noResult: true); + } + + @override + Future deleteActivitySamples({TimeWindow window = TimeWindow.all}) async { + final (clause, args) = _windowClause('start_ms', window); + return _database.delete( + tableActivity, + where: clause.isEmpty ? null : clause, + whereArgs: args, + ); + } + + @override + Future> queryActivitySamples({ + TimeWindow window = TimeWindow.all, + Set? types, + }) async { + final (where, args) = _whereWith( + 'start_ms', + window, + 'type', + types?.map((t) => t.key), + ); + final rows = await _database.query( + tableActivity, + columns: ['data'], + where: where, + whereArgs: args, + orderBy: 'start_ms ASC', + ); + return rows + .map((r) => ActivitySample.fromJson(_decode(r['data']))) + .toList(growable: false); + } + + // --- Sleep samples ------------------------------------------------------- + + @override + Future upsertSleepSamples(Iterable samples) async { + final batch = _database.batch(); + for (final s in samples) { + batch.insert(tableSleep, { + 'start_ms': _ms(s.start), + 'data': jsonEncode(s.toJson()), + }); + } + await batch.commit(noResult: true); + } + + @override + Future deleteSleepSamples({TimeWindow window = TimeWindow.all}) async { + final (clause, args) = _windowClause('start_ms', window); + return _database.delete( + tableSleep, + where: clause.isEmpty ? null : clause, + whereArgs: args, + ); + } + + @override + Future> querySleepSamples({ + TimeWindow window = TimeWindow.all, + }) async { + final (clause, args) = _windowClause('start_ms', window); + final rows = await _database.query( + tableSleep, + columns: ['data'], + where: clause.isEmpty ? null : clause, + whereArgs: args, + orderBy: 'start_ms ASC', + ); + return rows + .map((r) => SleepSample.fromJson(_decode(r['data']))) + .toList(growable: false); + } + + // --- Heart-rate samples -------------------------------------------------- + + @override + Future upsertHeartRateSamples(Iterable samples) async { + final batch = _database.batch(); + for (final s in samples) { + batch.insert(tableHeartRate, { + 'timestamp_ms': _ms(s.timestamp), + 'data': jsonEncode(s.toJson()), + }); + } + await batch.commit(noResult: true); + } + + @override + Future deleteHeartRateSamples({ + TimeWindow window = TimeWindow.all, + }) async { + final (clause, args) = _windowClause('timestamp_ms', window); + return _database.delete( + tableHeartRate, + where: clause.isEmpty ? null : clause, + whereArgs: args, + ); + } + + @override + Future> queryHeartRateSamples({ + TimeWindow window = TimeWindow.all, + }) async { + final (clause, args) = _windowClause('timestamp_ms', window); + final rows = await _database.query( + tableHeartRate, + columns: ['data'], + where: clause.isEmpty ? null : clause, + whereArgs: args, + orderBy: 'timestamp_ms ASC', + ); + return rows + .map((r) => HeartRateSample.fromJson(_decode(r['data']))) + .toList(growable: false); + } + + // --- AI insights --------------------------------------------------------- + + @override + Future upsertInsight(AiInsight insight) async { + await _database.insert(tableInsights, { + 'id': insight.id, + 'created_ms': _ms(insight.createdAt), + 'category': insight.category.key, + 'data': jsonEncode(insight.toJson()), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + @override + Future deleteInsight(String id) async { + await _database.delete(tableInsights, where: 'id = ?', whereArgs: [id]); + } + + @override + Future> queryInsights({ + TimeWindow window = TimeWindow.all, + Set? categories, + }) async { + final (where, args) = _whereWith( + 'created_ms', + window, + 'category', + categories?.map((c) => c.key), + ); + final rows = await _database.query( + tableInsights, + columns: ['data'], + where: where, + whereArgs: args, + orderBy: 'created_ms ASC', + ); + return rows + .map((r) => AiInsight.fromJson(_decode(r['data']))) + .toList(growable: false); + } + + @override + Future clear() async { + final batch = _database.batch(); + for (final table in const [ + tableEvents, + tableActivity, + tableSleep, + tableHeartRate, + tableInsights, + ]) { + batch.delete(table); + } + await batch.commit(noResult: true); + } + + static Map _decode(Object? data) { + return jsonDecode(data! as String) as Map; + } +} diff --git a/openhealth/pubspec.lock b/openhealth/pubspec.lock index 02ab3ac..bf62f62 100644 --- a/openhealth/pubspec.lock +++ b/openhealth/pubspec.lock @@ -101,6 +101,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -244,6 +252,22 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" image: dependency: transitive description: @@ -260,6 +284,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" json_annotation: dependency: transitive description: @@ -300,6 +340,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -324,14 +372,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" - path: + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96 + url: "https://pub.dev" + source: hosted + version: "0.19.1" + objective_c: dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -396,6 +492,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" rxdart: dependency: transitive description: @@ -473,6 +585,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" + url: "https://pub.dev" + source: hosted + version: "2.4.2+1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + sqflite_common_ffi: + dependency: "direct dev" + description: + name: sqflite_common_ffi + sha256: cd0c7f7de39a08f2d54ef144d9058c46eca8461879aaa648025643455c1e5a20 + url: "https://pub.dev" + source: hosted + version: "2.4.0+3" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "37356bcb56ce0d9404d602c41e4bdb7765e7e9732a3e47adb3d98c556a6abdad" + url: "https://pub.dev" + source: hosted + version: "3.3.3" stack_trace: dependency: transitive description: @@ -497,6 +665,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" term_glyph: dependency: transitive description: @@ -571,4 +747,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.11.4 <4.0.0" - flutter: ">=3.35.0" + flutter: ">=3.38.4" diff --git a/openhealth/pubspec.yaml b/openhealth/pubspec.yaml index 45292c8..d3295d5 100644 --- a/openhealth/pubspec.yaml +++ b/openhealth/pubspec.yaml @@ -18,12 +18,25 @@ dependencies: path: ../packages/cgm_core intl: ^0.20.2 shared_preferences: ^2.5.3 + # Local-first persistence for events / health-samples / AI insights. + # sqflite chosen over hive/isar: it's relational and query-friendly, so the + # repository's window/type filtered queries push down to SQL indexes instead + # of scanning in Dart, it scales to many imported sample rows, and it ships + # explicit schema-version migration hooks (onCreate/onUpgrade) we need here. + # It is the lowest-risk, most idiomatic on-device Flutter store and stays + # fully local (no cloud). + sqflite: ^2.4.1 + path: ^1.9.0 + path_provider: ^2.1.5 dev_dependencies: flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.14.3 flutter_test: sdk: flutter + # In-process SQLite (FFI) so the sqflite repository runs in `flutter test` + # on the host with no device/simulator. + sqflite_common_ffi: ^2.3.4 flutter: uses-material-design: true diff --git a/openhealth/test/sqflite_health_repository_test.dart b/openhealth/test/sqflite_health_repository_test.dart new file mode 100644 index 0000000..2306201 --- /dev/null +++ b/openhealth/test/sqflite_health_repository_test.dart @@ -0,0 +1,267 @@ +import 'package:cgm_core/cgm_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openglucose/src/persistence/sqflite_health_repository.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + // Run sqflite against in-process SQLite (FFI) so these tests need no device. + sqfliteFfiInit(); + + SqfliteHealthRepository newRepo() => SqfliteHealthRepository( + path: inMemoryDatabasePath, + databaseFactory: databaseFactoryFfi, + ); + + late SqfliteHealthRepository repo; + + setUp(() async { + repo = newRepo(); + await repo.init(); + }); + + tearDown(() async { + await repo.close(); + }); + + HealthEvent meal(String id, DateTime ts, {double? carbs}) => HealthEvent( + id: id, + timestamp: ts, + type: HealthEventType.meal, + payload: MealPayload(carbsGrams: carbs, description: 'm$id'), + ); + + group('events', () { + test('round-trips a single event with payload', () async { + await repo.upsertEvent(meal('e1', DateTime.utc(2026, 1, 1, 8), carbs: 42)); + final loaded = await repo.getEvent('e1'); + expect(loaded, isNotNull); + expect(loaded!.type, HealthEventType.meal); + expect((loaded.payload as MealPayload).carbsGrams, 42); + expect(loaded.timestamp, DateTime.utc(2026, 1, 1, 8)); + }); + + test('upsert replaces by id', () async { + await repo.upsertEvent(meal('e1', DateTime.utc(2026, 1, 1, 8), carbs: 10)); + await repo.upsertEvent(meal('e1', DateTime.utc(2026, 1, 1, 9), carbs: 99)); + final all = await repo.queryEvents(); + expect(all, hasLength(1)); + expect((all.single.payload as MealPayload).carbsGrams, 99); + }); + + test('bulk insert + half-open window query, sorted ascending', () async { + await repo.upsertEvents([ + meal('c', DateTime.utc(2026, 1, 1, 10)), + meal('a', DateTime.utc(2026, 1, 1, 6)), + meal('d', DateTime.utc(2026, 1, 1, 12)), + meal('b', DateTime.utc(2026, 1, 1, 8)), + ]); + final windowed = await repo.queryEvents( + window: TimeWindow( + start: DateTime.utc(2026, 1, 1, 8), + end: DateTime.utc(2026, 1, 1, 12), + ), + ); + expect(windowed.map((e) => e.id), ['b', 'c']); // start incl, end excl + }); + + test('filters by type', () async { + await repo.upsertEvents([ + meal('m', DateTime.utc(2026, 1, 1, 8)), + HealthEvent( + id: 'n', + timestamp: DateTime.utc(2026, 1, 1, 9), + type: HealthEventType.note, + payload: const NotePayload(text: 'hi'), + ), + ]); + final notes = await repo.queryEvents(types: {HealthEventType.note}); + expect(notes.map((e) => e.id), ['n']); + final none = await repo.queryEvents(types: const {}); + expect(none, isEmpty); + }); + + test('delete removes one event', () async { + await repo.upsertEvent(meal('e1', DateTime.utc(2026, 1, 1, 8))); + await repo.deleteEvent('e1'); + expect(await repo.getEvent('e1'), isNull); + }); + }); + + group('samples', () { + test('activity: bulk insert, type filter, delete-by-window', () async { + await repo.upsertActivitySamples([ + ActivitySample( + start: DateTime.utc(2026, 1, 1, 6), + end: DateTime.utc(2026, 1, 1, 7), + type: ActivityType.steps, + source: DataSource.appleHealth, + steps: 1000, + ), + ActivitySample( + start: DateTime.utc(2026, 1, 1, 9), + end: DateTime.utc(2026, 1, 1, 10), + type: ActivityType.workout, + source: DataSource.appleHealth, + workoutLabel: 'cycling', + ), + ]); + final steps = await repo.queryActivitySamples( + types: {ActivityType.steps}, + ); + expect(steps.single.steps, 1000); + + final removed = await repo.deleteActivitySamples( + window: TimeWindow( + start: DateTime.utc(2026, 1, 1, 6), + end: DateTime.utc(2026, 1, 1, 8), + ), + ); + expect(removed, 1); + final remaining = await repo.queryActivitySamples(); + expect(remaining.map((s) => s.workoutLabel), ['cycling']); + }); + + test('sleep: round-trip + window query', () async { + await repo.upsertSleepSamples([ + SleepSample( + start: DateTime.utc(2026, 1, 1, 23), + end: DateTime.utc(2026, 1, 2, 1), + stage: SleepStage.deep, + source: DataSource.healthConnect, + ), + SleepSample( + start: DateTime.utc(2026, 1, 2, 1), + end: DateTime.utc(2026, 1, 2, 2), + stage: SleepStage.rem, + source: DataSource.healthConnect, + ), + ]); + final all = await repo.querySleepSamples(); + expect(all.map((s) => s.stage), [SleepStage.deep, SleepStage.rem]); + final early = await repo.querySleepSamples( + window: TimeWindow(end: DateTime.utc(2026, 1, 2, 1)), + ); + expect(early.map((s) => s.stage), [SleepStage.deep]); + }); + + test('heart-rate: bulk insert, window query, delete-all', () async { + await repo.upsertHeartRateSamples([ + for (var i = 0; i < 5; i++) + HeartRateSample( + timestamp: DateTime.utc(2026, 1, 1, 8, i), + bpm: 60.0 + i, + source: DataSource.appleHealth, + ), + ]); + final mid = await repo.queryHeartRateSamples( + window: TimeWindow( + start: DateTime.utc(2026, 1, 1, 8, 1), + end: DateTime.utc(2026, 1, 1, 8, 4), + ), + ); + expect(mid.map((s) => s.bpm), [61, 62, 63]); + expect(await repo.deleteHeartRateSamples(), 5); + expect(await repo.queryHeartRateSamples(), isEmpty); + }); + }); + + group('AI insights', () { + test('upsert/replace, window + category filter, delete', () async { + await repo.upsertInsight( + AiInsight( + id: 'i1', + createdAt: DateTime.utc(2026, 1, 1, 9), + category: AiInsightCategory.pattern, + title: 'pattern', + ), + ); + await repo.upsertInsight( + AiInsight( + id: 'i2', + createdAt: DateTime.utc(2026, 1, 2, 9), + category: AiInsightCategory.summary, + title: 'summary', + ), + ); + await repo.upsertInsight( + AiInsight( + id: 'i1', + createdAt: DateTime.utc(2026, 1, 1, 9), + category: AiInsightCategory.pattern, + title: 'pattern v2', + ), + ); + final patterns = await repo.queryInsights( + categories: {AiInsightCategory.pattern}, + ); + expect(patterns.single.title, 'pattern v2'); + final firstDay = await repo.queryInsights( + window: TimeWindow(end: DateTime.utc(2026, 1, 2)), + ); + expect(firstDay.map((i) => i.id), ['i1']); + await repo.deleteInsight('i1'); + expect((await repo.queryInsights()).map((i) => i.id), ['i2']); + }); + }); + + test('clear wipes every table', () async { + await repo.upsertEvent(meal('e', DateTime.utc(2026, 1, 1))); + await repo.upsertHeartRateSamples([ + HeartRateSample( + timestamp: DateTime.utc(2026, 1, 1), + bpm: 70, + source: DataSource.manual, + ), + ]); + await repo.upsertInsight( + AiInsight( + id: 'i', + createdAt: DateTime.utc(2026, 1, 1), + category: AiInsightCategory.custom, + title: 't', + ), + ); + await repo.clear(); + expect(await repo.queryEvents(), isEmpty); + expect(await repo.queryHeartRateSamples(), isEmpty); + expect(await repo.queryInsights(), isEmpty); + }); + + group('schema / migrations', () { + test('init is idempotent and creates a usable schema', () async { + // init() already ran in setUp; calling again must be a no-op. + await repo.init(); + await repo.upsertEvent(meal('e', DateTime.utc(2026, 1, 1))); + expect(await repo.queryEvents(), hasLength(1)); + }); + + test('opens at the current schema version', () async { + final db = await databaseFactoryFfi.openDatabase( + inMemoryDatabasePath, + options: OpenDatabaseOptions(version: SqfliteHealthRepository.schemaVersion), + ); + expect( + await db.getVersion(), + SqfliteHealthRepository.schemaVersion, + ); + await db.close(); + }); + + test('onCreate runs the same migration path as a fresh open', () async { + // A second repository over a fresh in-memory db must come up clean, + // proving onCreate -> _migrate(0, latest) builds the full schema. + final other = newRepo(); + await other.init(); + await other.upsertInsight( + AiInsight( + id: 'x', + createdAt: DateTime.utc(2026, 1, 1), + category: AiInsightCategory.anomaly, + title: 'a', + ), + ); + expect(await other.queryInsights(), hasLength(1)); + await other.close(); + }); + }); +} diff --git a/packages/cgm_core/lib/cgm_core.dart b/packages/cgm_core/lib/cgm_core.dart index 4804d8d..c083219 100644 --- a/packages/cgm_core/lib/cgm_core.dart +++ b/packages/cgm_core/lib/cgm_core.dart @@ -1,7 +1,10 @@ library; +export 'src/ai_insight.dart'; export 'src/cgm_models.dart'; export 'src/cgm_session.dart'; export 'src/health_event.dart'; +export 'src/health_repository.dart'; export 'src/health_samples.dart'; +export 'src/in_memory_health_repository.dart'; export 'src/timeline.dart'; diff --git a/packages/cgm_core/lib/src/ai_insight.dart b/packages/cgm_core/lib/src/ai_insight.dart new file mode 100644 index 0000000..ed06ab1 --- /dev/null +++ b/packages/cgm_core/lib/src/ai_insight.dart @@ -0,0 +1,160 @@ +import 'timeline.dart'; + +/// The category of an [AiInsight]. +/// +/// Drives how the insight is rendered and lets callers filter the kinds of +/// AI-generated guidance they want to surface. +enum AiInsightCategory { + /// A correlation the model spotted (e.g. "high-carb dinners spike you"). + pattern, + + /// A concrete, actionable suggestion. + recommendation, + + /// A summary over a window (daily/weekly recap). + summary, + + /// A flagged anomaly worth the user's attention. + anomaly, + + /// Anything that does not fit the other categories. + custom; + + /// Stable string key used for serialization. + String get key => name; + + /// Parses an [AiInsightCategory] from its [key], falling back to [custom] + /// for unknown or missing values so deserialization never throws. + static AiInsightCategory fromKey(String? key) { + if (key == null) return AiInsightCategory.custom; + for (final value in AiInsightCategory.values) { + if (value.name == key) return value; + } + return AiInsightCategory.custom; + } +} + +/// An AI-generated insight derived from the user's timeline (CGM readings, +/// events, and imported samples). +/// +/// Insights are local-first artifacts: they are produced on-device (or by a +/// user-configured model) and persisted so the journal/AI surface can show a +/// history of guidance without recomputing it. The [window] records the span +/// of data the insight was derived from, so it can be re-anchored on the +/// timeline and invalidated when that span changes. +class AiInsight implements TimelineEntry { + const AiInsight({ + required this.id, + required this.createdAt, + required this.category, + required this.title, + this.body = '', + this.windowStart, + this.windowEnd, + this.confidence, + this.model, + this.tags = const [], + }); + + /// Stable unique identifier (caller-supplied; e.g. a UUID). + final String id; + + /// When the insight was generated. + final DateTime createdAt; + + /// The category of insight. + final AiInsightCategory category; + + /// Short headline shown in the UI. + final String title; + + /// Optional longer explanation/body text. + final String body; + + /// Start of the timeline window this insight was derived from, if any. + final DateTime? windowStart; + + /// End of the timeline window this insight was derived from, if any. + final DateTime? windowEnd; + + /// Optional model confidence in `[0, 1]`. + final double? confidence; + + /// Optional identifier of the model that produced the insight. + final String? model; + + /// Free-form tags for filtering and grouping. + final List tags; + + @override + DateTime get timelineTimestamp => createdAt; + + @override + TimelineEntryKind get timelineKind => TimelineEntryKind.aiInsight; + + AiInsight copyWith({ + String? id, + DateTime? createdAt, + AiInsightCategory? category, + String? title, + String? body, + DateTime? windowStart, + DateTime? windowEnd, + double? confidence, + String? model, + List? tags, + bool clearWindow = false, + bool clearConfidence = false, + bool clearModel = false, + }) { + return AiInsight( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + category: category ?? this.category, + title: title ?? this.title, + body: body ?? this.body, + windowStart: clearWindow ? null : (windowStart ?? this.windowStart), + windowEnd: clearWindow ? null : (windowEnd ?? this.windowEnd), + confidence: clearConfidence ? null : (confidence ?? this.confidence), + model: clearModel ? null : (model ?? this.model), + tags: tags ?? this.tags, + ); + } + + Map toJson() => { + 'id': id, + 'createdAt': createdAt.toIso8601String(), + 'category': category.key, + 'title': title, + 'body': body, + 'windowStart': windowStart?.toIso8601String(), + 'windowEnd': windowEnd?.toIso8601String(), + 'confidence': confidence, + 'model': model, + 'tags': tags, + }; + + factory AiInsight.fromJson(Map json) { + DateTime? parseOpt(Object? value) { + if (value is String && value.isNotEmpty) return DateTime.tryParse(value); + return null; + } + + return AiInsight( + id: json['id'] as String? ?? '', + createdAt: + DateTime.tryParse(json['createdAt'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + category: AiInsightCategory.fromKey(json['category'] as String?), + title: json['title'] as String? ?? '', + body: json['body'] as String? ?? '', + windowStart: parseOpt(json['windowStart']), + windowEnd: parseOpt(json['windowEnd']), + confidence: (json['confidence'] as num?)?.toDouble(), + model: json['model'] as String?, + tags: ((json['tags'] as List?) ?? const []) + .map((value) => '$value') + .toList(growable: false), + ); + } +} diff --git a/packages/cgm_core/lib/src/health_repository.dart b/packages/cgm_core/lib/src/health_repository.dart new file mode 100644 index 0000000..d9bd316 --- /dev/null +++ b/packages/cgm_core/lib/src/health_repository.dart @@ -0,0 +1,139 @@ +import 'ai_insight.dart'; +import 'health_event.dart'; +import 'health_samples.dart'; + +/// A half-open `[start, end)` time window used to scope timeline queries. +/// +/// `start` is inclusive and `end` is exclusive, which composes cleanly when +/// paging adjacent windows (no double-counting at the boundary). Either bound +/// may be omitted to leave that side unbounded. +class TimeWindow { + const TimeWindow({this.start, this.end}); + + /// Inclusive lower bound, or `null` for "from the beginning". + final DateTime? start; + + /// Exclusive upper bound, or `null` for "until now". + final DateTime? end; + + /// An unbounded window matching every record. + static const TimeWindow all = TimeWindow(); + + /// Whether [timestamp] falls inside this window. + bool contains(DateTime timestamp) { + if (start != null && timestamp.isBefore(start!)) return false; + if (end != null && !timestamp.isBefore(end!)) return false; + return true; + } +} + +/// The local-first persistence contract for journaled events, imported health +/// samples (activity / sleep / heart-rate), and AI insights. +/// +/// This is a pure-Dart interface so it can be implemented by an in-memory +/// fake for tests and by a concrete on-device store (sqflite) in the Flutter +/// app. It is intentionally local-only: nothing here implies or permits any +/// cloud sync. CGM reading history is owned by the existing reading-history +/// persistence and is deliberately out of scope. +/// +/// All timestamps are compared in their absolute instant; callers are +/// encouraged to store UTC. Queries return results sorted chronologically +/// ascending unless noted otherwise. +abstract interface class HealthRepository { + /// Opens/initializes the store (runs migrations). Safe to call more than + /// once; implementations should be idempotent. + Future init(); + + /// Releases any resources (closes the database). After [close] the + /// repository must not be used without calling [init] again. + Future close(); + + // --- Health events ------------------------------------------------------- + + /// Inserts a new event or replaces the existing one with the same + /// [HealthEvent.id]. + Future upsertEvent(HealthEvent event); + + /// Inserts/replaces many events in a single transaction. Intended for + /// imports; ordering of [events] is not significant. + Future upsertEvents(Iterable events); + + /// Removes the event with [id]. No-op if it does not exist. + Future deleteEvent(String id); + + /// Returns the event with [id], or `null` if absent. + Future getEvent(String id); + + /// Returns events whose [HealthEvent.timestamp] falls in [window], optionally + /// filtered to [types], sorted chronologically ascending. + Future> queryEvents({ + TimeWindow window = TimeWindow.all, + Set? types, + }); + + // --- Activity samples ---------------------------------------------------- + + /// Inserts/replaces many activity samples in a single transaction. + Future upsertActivitySamples(Iterable samples); + + /// Removes every activity sample whose [ActivitySample.start] falls in + /// [window]. Returns the number of rows removed. + Future deleteActivitySamples({TimeWindow window = TimeWindow.all}); + + /// Returns activity samples whose [ActivitySample.start] falls in [window], + /// optionally filtered to [types], sorted chronologically ascending. + Future> queryActivitySamples({ + TimeWindow window = TimeWindow.all, + Set? types, + }); + + // --- Sleep samples ------------------------------------------------------- + + /// Inserts/replaces many sleep samples in a single transaction. + Future upsertSleepSamples(Iterable samples); + + /// Removes every sleep sample whose [SleepSample.start] falls in [window]. + /// Returns the number of rows removed. + Future deleteSleepSamples({TimeWindow window = TimeWindow.all}); + + /// Returns sleep samples whose [SleepSample.start] falls in [window], sorted + /// chronologically ascending. + Future> querySleepSamples({ + TimeWindow window = TimeWindow.all, + }); + + // --- Heart-rate samples -------------------------------------------------- + + /// Inserts/replaces many heart-rate samples in a single transaction. + Future upsertHeartRateSamples(Iterable samples); + + /// Removes every heart-rate sample whose [HeartRateSample.timestamp] falls in + /// [window]. Returns the number of rows removed. + Future deleteHeartRateSamples({TimeWindow window = TimeWindow.all}); + + /// Returns heart-rate samples whose [HeartRateSample.timestamp] falls in + /// [window], sorted chronologically ascending. + Future> queryHeartRateSamples({ + TimeWindow window = TimeWindow.all, + }); + + // --- AI insights --------------------------------------------------------- + + /// Inserts a new insight or replaces the existing one with the same + /// [AiInsight.id]. + Future upsertInsight(AiInsight insight); + + /// Removes the insight with [id]. No-op if it does not exist. + Future deleteInsight(String id); + + /// Returns insights whose [AiInsight.createdAt] falls in [window], optionally + /// filtered to [categories], sorted chronologically ascending. + Future> queryInsights({ + TimeWindow window = TimeWindow.all, + Set? categories, + }); + + /// Removes every record (events, all sample types, insights). Intended for + /// "reset local data" / test teardown. Does not touch CGM reading history. + Future clear(); +} diff --git a/packages/cgm_core/lib/src/in_memory_health_repository.dart b/packages/cgm_core/lib/src/in_memory_health_repository.dart new file mode 100644 index 0000000..430996d --- /dev/null +++ b/packages/cgm_core/lib/src/in_memory_health_repository.dart @@ -0,0 +1,172 @@ +import 'ai_insight.dart'; +import 'health_event.dart'; +import 'health_repository.dart'; +import 'health_samples.dart'; +import 'timeline.dart'; + +/// An in-memory [HealthRepository] for tests and ephemeral usage. +/// +/// It mirrors the semantics the on-device store must honor (id-keyed upserts +/// for events/insights, window + type filtering, chronological ordering, +/// window-scoped deletes), so unit tests can exercise the repository contract +/// with no device, file system, or sqflite dependency. +/// +/// Samples have no stable identity, so [upsertActivitySamples] and friends +/// append rather than de-duplicate — matching how the relational store treats +/// bulk-imported samples as append-only rows. +class InMemoryHealthRepository implements HealthRepository { + final Map _events = {}; + final List _activity = []; + final List _sleep = []; + final List _heartRate = []; + final Map _insights = {}; + + @override + Future init() async {} + + @override + Future close() async {} + + // --- Health events ------------------------------------------------------- + + @override + Future upsertEvent(HealthEvent event) async { + _events[event.id] = event; + } + + @override + Future upsertEvents(Iterable events) async { + for (final event in events) { + _events[event.id] = event; + } + } + + @override + Future deleteEvent(String id) async { + _events.remove(id); + } + + @override + Future getEvent(String id) async => _events[id]; + + @override + Future> queryEvents({ + TimeWindow window = TimeWindow.all, + Set? types, + }) async { + return _events.values + .where((e) => window.contains(e.timestamp)) + .where((e) => types == null || types.contains(e.type)) + .toList() + .sortedByTime(); + } + + // --- Activity samples ---------------------------------------------------- + + @override + Future upsertActivitySamples(Iterable samples) async { + _activity.addAll(samples); + } + + @override + Future deleteActivitySamples({TimeWindow window = TimeWindow.all}) async { + return _removeWhere(_activity, (s) => window.contains(s.start)); + } + + @override + Future> queryActivitySamples({ + TimeWindow window = TimeWindow.all, + Set? types, + }) async { + return _activity + .where((s) => window.contains(s.start)) + .where((s) => types == null || types.contains(s.type)) + .toList() + .sortedByTime(); + } + + // --- Sleep samples ------------------------------------------------------- + + @override + Future upsertSleepSamples(Iterable samples) async { + _sleep.addAll(samples); + } + + @override + Future deleteSleepSamples({TimeWindow window = TimeWindow.all}) async { + return _removeWhere(_sleep, (s) => window.contains(s.start)); + } + + @override + Future> querySleepSamples({ + TimeWindow window = TimeWindow.all, + }) async { + return _sleep + .where((s) => window.contains(s.start)) + .toList() + .sortedByTime(); + } + + // --- Heart-rate samples -------------------------------------------------- + + @override + Future upsertHeartRateSamples(Iterable samples) async { + _heartRate.addAll(samples); + } + + @override + Future deleteHeartRateSamples({ + TimeWindow window = TimeWindow.all, + }) async { + return _removeWhere(_heartRate, (s) => window.contains(s.timestamp)); + } + + @override + Future> queryHeartRateSamples({ + TimeWindow window = TimeWindow.all, + }) async { + return _heartRate + .where((s) => window.contains(s.timestamp)) + .toList() + .sortedByTime(); + } + + // --- AI insights --------------------------------------------------------- + + @override + Future upsertInsight(AiInsight insight) async { + _insights[insight.id] = insight; + } + + @override + Future deleteInsight(String id) async { + _insights.remove(id); + } + + @override + Future> queryInsights({ + TimeWindow window = TimeWindow.all, + Set? categories, + }) async { + return _insights.values + .where((i) => window.contains(i.createdAt)) + .where((i) => categories == null || categories.contains(i.category)) + .toList() + .sortedByTime(); + } + + @override + Future clear() async { + _events.clear(); + _activity.clear(); + _sleep.clear(); + _heartRate.clear(); + _insights.clear(); + } + + static int _removeWhere(List list, bool Function(T) test) { + final before = list.length; + list.removeWhere(test); + return before - list.length; + } +} diff --git a/packages/cgm_core/lib/src/timeline.dart b/packages/cgm_core/lib/src/timeline.dart index 2a242aa..98997f8 100644 --- a/packages/cgm_core/lib/src/timeline.dart +++ b/packages/cgm_core/lib/src/timeline.dart @@ -45,6 +45,9 @@ enum TimelineEntryKind { /// A heart-rate sample (`HeartRateSample`). heartRate, + + /// An AI-generated insight (`AiInsight`). + aiInsight, } /// A common interface that lets heterogeneous health data — CGM readings, diff --git a/packages/cgm_core/test/ai_insight_test.dart b/packages/cgm_core/test/ai_insight_test.dart new file mode 100644 index 0000000..5a4dd17 --- /dev/null +++ b/packages/cgm_core/test/ai_insight_test.dart @@ -0,0 +1,73 @@ +import 'package:cgm_core/cgm_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('AiInsight', () { + test('exposes timeline contract via createdAt', () { + final insight = AiInsight( + id: 'i1', + createdAt: DateTime.utc(2026, 1, 1, 9), + category: AiInsightCategory.recommendation, + title: 'Try a walk after dinner', + ); + expect(insight.timelineTimestamp, DateTime.utc(2026, 1, 1, 9)); + expect(insight.timelineKind, TimelineEntryKind.aiInsight); + expect(insight.body, ''); + expect(insight.tags, isEmpty); + }); + + test('round-trips through JSON', () { + final insight = AiInsight( + id: 'i2', + createdAt: DateTime.utc(2026, 2, 3, 12, 30), + category: AiInsightCategory.pattern, + title: 'High-carb dinners spike you', + body: 'On 4 of 5 days a >60g dinner preceded a >180 mg/dL peak.', + windowStart: DateTime.utc(2026, 2, 1), + windowEnd: DateTime.utc(2026, 2, 3), + confidence: 0.82, + model: 'on-device-v1', + tags: const ['carbs', 'dinner'], + ); + final restored = AiInsight.fromJson(insight.toJson()); + expect(restored.id, insight.id); + expect(restored.createdAt, insight.createdAt); + expect(restored.category, AiInsightCategory.pattern); + expect(restored.title, insight.title); + expect(restored.body, insight.body); + expect(restored.windowStart, insight.windowStart); + expect(restored.windowEnd, insight.windowEnd); + expect(restored.confidence, 0.82); + expect(restored.model, 'on-device-v1'); + expect(restored.tags, insight.tags); + }); + + test('unknown category falls back to custom', () { + expect(AiInsightCategory.fromKey('nope'), AiInsightCategory.custom); + expect(AiInsightCategory.fromKey(null), AiInsightCategory.custom); + }); + + test('copyWith can clear optional fields', () { + final insight = AiInsight( + id: 'i3', + createdAt: DateTime.utc(2026, 1, 1), + category: AiInsightCategory.summary, + title: 'Weekly recap', + windowStart: DateTime.utc(2025, 12, 25), + windowEnd: DateTime.utc(2026, 1, 1), + confidence: 0.5, + model: 'm', + ); + final cleared = insight.copyWith( + clearWindow: true, + clearConfidence: true, + clearModel: true, + ); + expect(cleared.windowStart, isNull); + expect(cleared.windowEnd, isNull); + expect(cleared.confidence, isNull); + expect(cleared.model, isNull); + expect(cleared.title, 'Weekly recap'); + }); + }); +} diff --git a/packages/cgm_core/test/health_repository_contract.dart b/packages/cgm_core/test/health_repository_contract.dart new file mode 100644 index 0000000..5b74bfb --- /dev/null +++ b/packages/cgm_core/test/health_repository_contract.dart @@ -0,0 +1,253 @@ +import 'package:cgm_core/cgm_core.dart'; +import 'package:test/test.dart'; + +/// A reusable contract test suite for any [HealthRepository] implementation. +/// +/// [factory] must return a fresh, empty repository each time it is called. +/// The same suite runs against the in-memory fake here and against the +/// sqflite-backed store in the Flutter app, guaranteeing they agree. +void runHealthRepositoryContractTests( + HealthRepository Function() factory, +) { + late HealthRepository repo; + + setUp(() async { + repo = factory(); + await repo.init(); + }); + + tearDown(() async { + await repo.close(); + }); + + HealthEvent meal(String id, DateTime ts, {double? carbs}) => HealthEvent( + id: id, + timestamp: ts, + type: HealthEventType.meal, + payload: MealPayload(carbsGrams: carbs, description: 'm$id'), + ); + + group('events', () { + test('round-trips a single event', () async { + final e = meal('e1', DateTime.utc(2026, 1, 1, 8), carbs: 42); + await repo.upsertEvent(e); + + final loaded = await repo.getEvent('e1'); + expect(loaded, isNotNull); + expect(loaded!.id, 'e1'); + expect(loaded.type, HealthEventType.meal); + expect((loaded.payload as MealPayload).carbsGrams, 42); + expect(loaded.timestamp, DateTime.utc(2026, 1, 1, 8)); + }); + + test('upsert replaces by id', () async { + await repo.upsertEvent(meal('e1', DateTime.utc(2026, 1, 1, 8), carbs: 10)); + await repo.upsertEvent(meal('e1', DateTime.utc(2026, 1, 1, 9), carbs: 99)); + + final all = await repo.queryEvents(); + expect(all, hasLength(1)); + expect(all.single.timestamp, DateTime.utc(2026, 1, 1, 9)); + expect((all.single.payload as MealPayload).carbsGrams, 99); + }); + + test('bulk insert and query-by-window (half-open) + chronological', () async { + await repo.upsertEvents([ + meal('a', DateTime.utc(2026, 1, 1, 6)), + meal('b', DateTime.utc(2026, 1, 1, 8)), + meal('c', DateTime.utc(2026, 1, 1, 10)), + meal('d', DateTime.utc(2026, 1, 1, 12)), + ]); + + final windowed = await repo.queryEvents( + window: TimeWindow( + start: DateTime.utc(2026, 1, 1, 8), + end: DateTime.utc(2026, 1, 1, 12), + ), + ); + // start inclusive (b), end exclusive (d excluded). + expect(windowed.map((e) => e.id), ['b', 'c']); + }); + + test('query filters by type', () async { + await repo.upsertEvents([ + meal('m', DateTime.utc(2026, 1, 1, 8)), + HealthEvent( + id: 'n', + timestamp: DateTime.utc(2026, 1, 1, 9), + type: HealthEventType.note, + payload: const NotePayload(text: 'hi'), + ), + ]); + + final notes = await repo.queryEvents(types: {HealthEventType.note}); + expect(notes.map((e) => e.id), ['n']); + }); + + test('delete removes one event', () async { + await repo.upsertEvent(meal('e1', DateTime.utc(2026, 1, 1, 8))); + await repo.deleteEvent('e1'); + expect(await repo.getEvent('e1'), isNull); + expect(await repo.queryEvents(), isEmpty); + }); + }); + + group('activity samples', () { + test('bulk insert, window + type filter, delete-by-window', () async { + await repo.upsertActivitySamples([ + ActivitySample( + start: DateTime.utc(2026, 1, 1, 6), + end: DateTime.utc(2026, 1, 1, 7), + type: ActivityType.steps, + source: DataSource.appleHealth, + steps: 1000, + ), + ActivitySample( + start: DateTime.utc(2026, 1, 1, 9), + end: DateTime.utc(2026, 1, 1, 10), + type: ActivityType.workout, + source: DataSource.appleHealth, + workoutLabel: 'cycling', + ), + ]); + + final steps = await repo.queryActivitySamples( + types: {ActivityType.steps}, + ); + expect(steps, hasLength(1)); + expect(steps.single.steps, 1000); + + final removed = await repo.deleteActivitySamples( + window: TimeWindow( + start: DateTime.utc(2026, 1, 1, 6), + end: DateTime.utc(2026, 1, 1, 8), + ), + ); + expect(removed, 1); + final remaining = await repo.queryActivitySamples(); + expect(remaining.map((s) => s.workoutLabel), ['cycling']); + }); + }); + + group('sleep samples', () { + test('round-trip + window query', () async { + await repo.upsertSleepSamples([ + SleepSample( + start: DateTime.utc(2026, 1, 1, 23), + end: DateTime.utc(2026, 1, 2, 1), + stage: SleepStage.deep, + source: DataSource.healthConnect, + ), + SleepSample( + start: DateTime.utc(2026, 1, 2, 1), + end: DateTime.utc(2026, 1, 2, 2), + stage: SleepStage.rem, + source: DataSource.healthConnect, + ), + ]); + + final all = await repo.querySleepSamples(); + expect(all.map((s) => s.stage), [SleepStage.deep, SleepStage.rem]); + + final early = await repo.querySleepSamples( + window: TimeWindow(end: DateTime.utc(2026, 1, 2, 1)), + ); + expect(early.map((s) => s.stage), [SleepStage.deep]); + }); + }); + + group('heart-rate samples', () { + test('bulk insert + window query + delete-by-window', () async { + await repo.upsertHeartRateSamples([ + for (var i = 0; i < 5; i++) + HeartRateSample( + timestamp: DateTime.utc(2026, 1, 1, 8, i), + bpm: 60.0 + i, + source: DataSource.appleHealth, + ), + ]); + + final mid = await repo.queryHeartRateSamples( + window: TimeWindow( + start: DateTime.utc(2026, 1, 1, 8, 1), + end: DateTime.utc(2026, 1, 1, 8, 4), + ), + ); + expect(mid.map((s) => s.bpm), [61, 62, 63]); + + final removed = await repo.deleteHeartRateSamples(); + expect(removed, 5); + expect(await repo.queryHeartRateSamples(), isEmpty); + }); + }); + + group('AI insights', () { + test('upsert/replace, window+category filter, delete', () async { + await repo.upsertInsight( + AiInsight( + id: 'i1', + createdAt: DateTime.utc(2026, 1, 1, 9), + category: AiInsightCategory.pattern, + title: 'pattern', + ), + ); + await repo.upsertInsight( + AiInsight( + id: 'i2', + createdAt: DateTime.utc(2026, 1, 2, 9), + category: AiInsightCategory.summary, + title: 'summary', + ), + ); + // Replace i1 in place. + await repo.upsertInsight( + AiInsight( + id: 'i1', + createdAt: DateTime.utc(2026, 1, 1, 9), + category: AiInsightCategory.pattern, + title: 'pattern v2', + ), + ); + + final patterns = await repo.queryInsights( + categories: {AiInsightCategory.pattern}, + ); + expect(patterns, hasLength(1)); + expect(patterns.single.title, 'pattern v2'); + + final firstDay = await repo.queryInsights( + window: TimeWindow(end: DateTime.utc(2026, 1, 2)), + ); + expect(firstDay.map((i) => i.id), ['i1']); + + await repo.deleteInsight('i1'); + final left = await repo.queryInsights(); + expect(left.map((i) => i.id), ['i2']); + }); + }); + + test('clear wipes everything', () async { + await repo.upsertEvent(meal('e', DateTime.utc(2026, 1, 1))); + await repo.upsertSleepSamples([ + SleepSample( + start: DateTime.utc(2026, 1, 1), + end: DateTime.utc(2026, 1, 1, 1), + stage: SleepStage.light, + source: DataSource.manual, + ), + ]); + await repo.upsertInsight( + AiInsight( + id: 'i', + createdAt: DateTime.utc(2026, 1, 1), + category: AiInsightCategory.custom, + title: 't', + ), + ); + + await repo.clear(); + + expect(await repo.queryEvents(), isEmpty); + expect(await repo.querySleepSamples(), isEmpty); + expect(await repo.queryInsights(), isEmpty); + }); +} diff --git a/packages/cgm_core/test/in_memory_health_repository_test.dart b/packages/cgm_core/test/in_memory_health_repository_test.dart new file mode 100644 index 0000000..3db79fe --- /dev/null +++ b/packages/cgm_core/test/in_memory_health_repository_test.dart @@ -0,0 +1,7 @@ +import 'package:cgm_core/cgm_core.dart'; + +import 'health_repository_contract.dart'; + +void main() { + runHealthRepositoryContractTests(InMemoryHealthRepository.new); +}