diff --git a/lib/data/csv_export.dart b/lib/data/csv_export.dart new file mode 100644 index 0000000..519e1ae --- /dev/null +++ b/lib/data/csv_export.dart @@ -0,0 +1,276 @@ +// CSV export — your data in a shape a spreadsheet can open. +// +// The whole-database export already exists and is the complete, lossless +// thing; this is the one people actually asked for, because "open it in +// Excel" and "restore it onto another phone" are different jobs and a SQLite +// file only does the second. +// +// Everything here reads through the derived views the coach already reads, so +// an export can never contain something the app itself would not show you, and +// it can never reach raw sensor rows or GPS. +// +// Absence is written as an EMPTY FIELD, never as 0. A spreadsheet cannot tell +// the difference afterwards, and a column of zeroes where a metric was simply +// not computed is the same fabrication the rest of the app refuses to make — +// except now it is in a file the user will average. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'db.dart'; + +/// One exportable table: a filename stem, a header, and the query behind it. +class CsvExportSet { + const CsvExportSet({ + required this.name, + required this.title, + required this.columns, + required this.sql, + }); + + /// Filename stem, e.g. `daily` → `openstrap_daily_.csv`. + final String name; + final String title; + final List columns; + final String sql; +} + +/// What can be exported. Ordered as the picker shows them. +const kCsvExportSets = [ + CsvExportSet( + name: 'daily', + title: 'Daily metrics', + columns: [ + 'date', + 'readiness', + 'resting_hr', + 'hrv', + 'sdnn', + 'resp_rate', + 'stress', + 'strain', + 'active_calories', + 'total_calories', + 'sleep_min', + 'deep_min', + 'rem_min', + 'light_min', + 'nap_min', + 'sleep_efficiency', + 'steps', + 'worn_min', + ], + sql: ''' + SELECT date, readiness, resting_hr, hrv, sdnn, resp_rate, stress, strain, + active_calories, total_calories, sleep_min, deep_min, rem_min, + light_min, nap_min, sleep_efficiency, steps, worn_min + FROM v_daily ORDER BY date ASC + ''', + ), + CsvExportSet( + name: 'workouts', + title: 'Workouts', + columns: [ + 'date', + 'start_ts', + 'end_ts', + 'type', + 'status', + 'duration_min', + 'strain', + 'calories', + 'max_hr', + 'steps', + 'hrr_bpm', + 'source', + ], + sql: ''' + SELECT date, start_ts, end_ts, type, status, duration_min, strain, + calories, max_hr, steps, hrr_bpm, source + FROM v_sessions ORDER BY start_ts ASC + ''', + ), + CsvExportSet( + name: 'sleep', + title: 'Sleep stages', + columns: ['date', 'start_ts', 'end_ts', 'stage'], + sql: 'SELECT date, start_ts, end_ts, stage FROM v_hypnogram ' + 'ORDER BY date ASC, start_ts ASC', + ), + CsvExportSet( + name: 'metrics', + title: 'Metric history', + columns: ['date', 'key', 'value'], + sql: 'SELECT date, key, value FROM v_metric ORDER BY date ASC, key ASC', + ), + CsvExportSet( + name: 'journal', + title: 'Journal', + columns: ['date', 'tags', 'note'], + sql: "SELECT date, replace(tags_json, char(10), ' ') AS tags, note " + 'FROM journal ORDER BY date ASC', + ), + CsvExportSet( + name: 'labs', + title: 'Lab results', + columns: ['taken_on', 'marker', 'value', 'unit', 'note'], + sql: 'SELECT taken_on, marker, value, unit, note FROM lab_result ' + 'ORDER BY taken_on ASC, marker ASC', + ), +]; + +/// Characters that make Excel, Google Sheets and LibreOffice treat a cell as a +/// FORMULA rather than text. +/// +/// Journal notes, journal tags and lab notes are free text the user typed, and +/// these files are handed to a share sheet — so whoever opens the spreadsheet +/// executes whatever a cell starting with one of these evaluates to. A note +/// beginning "=" is a formula in every mainstream spreadsheet, and formulas +/// can reach the network and the filesystem. +final _formulaLeaders = RegExp(r'^[=+\-@\t\r]'); + +/// RFC 4180 field escaping, plus formula-injection neutralisation. +/// +/// Null becomes an EMPTY field rather than the string "null" or a 0 — the +/// distinction between "not measured" and "measured as nothing" has to survive +/// into the file, because nobody can recover it once it is a spreadsheet. +String csvField(Object? v) { + if (v == null) return ''; + var s = v is double + // Whole doubles as integers: `55.0` in a resting-HR column invites a + // false impression of precision the metric does not have. + ? (v == v.roundToDouble() ? v.toInt().toString() : v.toString()) + : v.toString(); + // A leading apostrophe is the convention every mainstream spreadsheet reads + // as "this is text" — it is not displayed, and the value stays legible. + // Applied only to strings: a negative NUMBER starts with `-` and must stay a + // number, or every negative delta in the file becomes unusable text. + if (v is String && _formulaLeaders.hasMatch(s)) s = "'$s"; + if (s.contains(RegExp('[",\n\r]'))) { + return '"${s.replaceAll('"', '""')}"'; + } + return s; +} + +String csvRow(Iterable values) => values.map(csvField).join(','); + +/// Render [rows] under [columns]. A column missing from a row is empty, not +/// dropped, so every line has the same field count. +String renderCsv(List columns, List> rows) { + final b = StringBuffer()..writeln(csvRow(columns)); + for (final r in rows) { + b.writeln(csvRow([for (final c in columns) r[c]])); + } + return b.toString(); +} + +/// What an export produced. +class CsvExportResult { + const CsvExportResult({required this.paths, required this.failed}); + + /// Files actually written. A set with no rows writes nothing, so an empty + /// list here genuinely means there was nothing to export. + final List paths; + + /// Sets whose query or write threw, by name. Kept separate from [paths] so + /// the caller can tell "you have no data yet" apart from "the export broke", + /// which the previous single-list return could not express — a total failure + /// looked exactly like an empty database. + final List failed; + + bool get isEmpty => paths.isEmpty; + bool get hasFailures => failed.isNotEmpty; +} + +/// Parent directory for CSV exports. Each run gets its own subdirectory under +/// it, named by timestamp. +const _csvDirName = 'openstrap_csv'; + +/// How many runs survive a cleanup. +/// +/// Not one. `exportCsvFiles` returns before the caller has finished handing +/// the files to a share sheet, and the share target reads them lazily — so +/// wiping every earlier run at the start of a new one would delete files out +/// from under a share session that was still open. Keeping the previous run +/// as well means a second export cannot destroy the first one's files, while +/// still bounding how many copies of plaintext health data survive on disk. +const _csvRunsKept = 2; + +/// Write the chosen [sets] to CSV files and return what landed. +/// +/// The output directory is WIPED first. These files are plaintext readiness, +/// sleep, journal notes and lab results, and they were previously left in the +/// temp directory indefinitely under a unique per-run stamp, so every export +/// added another copy that nothing ever removed. One export's worth exists at +/// a time now. +Future exportCsvFiles( + List sets, { + DateTime? now, +}) async { + final db = await LocalDb.instance; + final root = await getTemporaryDirectory(); + final parent = Directory(p.join(root.path, _csvDirName)); + await parent.create(recursive: true); + + final stamp = (now ?? DateTime.now()).millisecondsSinceEpoch; + final dir = Directory(p.join(parent.path, '$stamp')); + await dir.create(recursive: true); + await _pruneOldRuns(parent, keep: _csvRunsKept); + final paths = []; + final failed = []; + + for (final set in sets) { + try { + final rows = await db.rawQuery(set.sql); + // No rows means no file. A header-only CSV is not "your data", and + // emitting one made an empty database indistinguishable from a working + // export to the caller. + if (rows.isEmpty) continue; + final file = File(p.join(dir.path, 'openstrap_${set.name}_$stamp.csv')); + // utf8 with a BOM: without it Excel on Windows reads the file as the + // local code page and mangles every non-ASCII character in a note. + await file.writeAsBytes([ + 0xEF, + 0xBB, + 0xBF, + ...utf8.encode(renderCsv(set.columns, rows)), + ]); + paths.add(file.path); + } catch (_) { + // One set failing must not lose the other five — but it is reported + // rather than swallowed, which is what the bare catch used to do. + failed.add(set.name); + } + } + return CsvExportResult(paths: paths, failed: failed); +} + +/// Delete all but the [keep] newest run directories under [parent]. +/// +/// Run directories are named by millisecond timestamp, so a lexicographic sort +/// over equal-length names is chronological. Anything that is not a plausible +/// run directory is left alone rather than deleted — this runs inside the +/// app's temp directory and must never reach beyond its own folder. +Future _pruneOldRuns(Directory parent, {required int keep}) async { + try { + final runs = + parent + .listSync() + .whereType() + .where((d) => int.tryParse(p.basename(d.path)) != null) + .toList() + ..sort((a, b) { + final ai = int.parse(p.basename(a.path)); + final bi = int.parse(p.basename(b.path)); + return bi.compareTo(ai); + }); + for (final old in runs.skip(keep)) { + await old.delete(recursive: true); + } + } catch (_) { + // Housekeeping only — a failure here must never fail the export itself. + } +} diff --git a/lib/data/db.dart b/lib/data/db.dart index a4a85ca..676e2a7 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -91,7 +91,7 @@ class LocalDb { /// pass it: sqflite throws `ArgumentError('onCreate must be null if no /// version is specified')` BEFORE opening anything when `onCreate` is given /// without `version` (sqflite_common database_mixin.dart). - static const int schemaVersion = 28; + static const int schemaVersion = 29; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -411,6 +411,11 @@ class LocalDb { await _createJournalMetric(db); await _createJournalFieldDef(db); } + if (oldV < 29) { + // Hand-entered blood work. Purely new tables; nothing existing is + // read or rewritten. + await _createLabTables(db); + } }, onOpen: (db) async { await _repairOpenSchema(db); @@ -1364,6 +1369,55 @@ class LocalDb { '''); } + /// lab_result — hand-entered blood work, and definitions for user-defined + /// markers. + /// + /// Keyed on (marker, taken_on) so re-entering the same draw corrects it + /// rather than stacking duplicates; two genuinely different draws on one day + /// are rare enough that correcting a typo is the case worth optimising for. + /// + /// `unit` is stored per row rather than looked up from the catalogue, so a + /// value keeps the unit it was entered under even if a later release changes + /// the marker's canonical unit. Silently reinterpreting 400 ng/mL as + /// 400 nmol/L would be a fabrication of the worst kind. + /// + /// NOT day-scoped, NOT pruned, and deliberately NOT removed by `deleteDays`. + /// A lab result belongs to the date the blood was drawn, not to a band-data + /// day. "Delete this day" in the data manager is about reclaiming space from + /// sensor data; a blood test is neither sensor data nor large, it was typed + /// in by hand on a different screen, and it has its own delete there. Losing + /// a year-old blood panel because the band data from that date was cleared + /// would be a genuinely surprising deletion. + /// + /// Indexed by its PRIMARY KEY alone — `(marker, taken_on)` already gives + /// SQLite an implicit index on exactly the columns every read here filters + /// and orders by, so a second one would only be another b-tree to maintain. + static Future _createLabTables(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS lab_result ( + marker TEXT NOT NULL, + taken_on TEXT NOT NULL, + value REAL NOT NULL, + unit TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, + PRIMARY KEY (marker, taken_on) + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS lab_marker_def ( + key TEXT PRIMARY KEY, + label TEXT NOT NULL, + unit TEXT NOT NULL, + category TEXT NOT NULL, + decimals INTEGER NOT NULL DEFAULT 1, + ref_low REAL, + ref_high REAL, + created_at INTEGER NOT NULL + ) + '''); + } + // ── USER-DATA STORE (journal / cycle / workouts / notifications) ──────────── // On-device user-entered + locally-generated data. All keyed for idempotent // upserts; none of it round-trips to a server (cloud excised). @@ -1379,6 +1433,7 @@ class LocalDb { '''); await _createJournalMetric(db); await _createJournalFieldDef(db); + await _createLabTables(db); // cycle_log — menstrual cycle markers; `kind` is 'start' (cycle start) etc. await db.execute(''' CREATE TABLE IF NOT EXISTS cycle_log ( @@ -3613,6 +3668,8 @@ class LocalDb { 'journal', 'journal_metric', 'journal_field_def', + 'lab_result', + 'lab_marker_def', 'cycle_log', 'notifications', 'baselines', @@ -3921,6 +3978,8 @@ class LocalDb { 'journal', 'journal_metric', 'journal_field_def', + 'lab_result', + 'lab_marker_def', 'cycle_log', 'notifications', 'sync_cursor', @@ -4714,6 +4773,62 @@ class LocalDb { }, conflictAlgorithm: ConflictAlgorithm.replace); } + // ── lab results ─────────────────────────────────────────────────────────── + + /// Upsert one result. Idempotent on (marker, date drawn), so re-entering a + /// value corrects it instead of stacking a near-duplicate. + static Future putLabResult({ + required String marker, + required String takenOn, + required double value, + required String unit, + String note = '', + }) async { + final db = await instance; + await db.insert('lab_result', { + 'marker': marker, + 'taken_on': takenOn, + 'value': value, + 'unit': unit, + 'note': note, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future deleteLabResult(String marker, String takenOn) async { + final db = await instance; + await db.delete( + 'lab_result', + where: 'marker = ? AND taken_on = ?', + whereArgs: [marker, takenOn], + ); + } + + /// Every result, newest draw first. [marker] narrows to one series. + static Future>> labResults({String? marker}) async { + final db = await instance; + return db.query( + 'lab_result', + where: marker == null ? null : 'marker = ?', + whereArgs: marker == null ? null : [marker], + orderBy: 'taken_on DESC', + ); + } + + /// Custom marker definitions, by label. + static Future>> labMarkerDefs() async { + final db = await instance; + return db.query('lab_marker_def', orderBy: 'label ASC'); + } + + static Future putLabMarkerDef(Map row) async { + final db = await instance; + await db.insert('lab_marker_def', { + ...row, + 'created_at': DateTime.now().millisecondsSinceEpoch, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + /// Forget a custom field's DEFINITION. Its recorded values are deliberately /// left alone — they were real readings, and deleting a label should not /// delete history. @@ -4722,6 +4837,14 @@ class LocalDb { await db.delete('journal_field_def', where: 'key = ?', whereArgs: [key]); } + /// Forget a custom marker's DEFINITION. Its results are left alone — those + /// were real draws, and each row already carries its own unit, so they stay + /// readable without it. + static Future deleteLabMarkerDef(String key) async { + final db = await instance; + await db.delete('lab_marker_def', where: 'key = ?', whereArgs: [key]); + } + // ── cycle log I/O ───────────────────────────────────────────────────────────── static Future putCycleLog( diff --git a/lib/data/lab_catalogue.dart b/lib/data/lab_catalogue.dart new file mode 100644 index 0000000..fe1ac1d --- /dev/null +++ b/lib/data/lab_catalogue.dart @@ -0,0 +1,394 @@ +// Blood-work markers you can enter by hand — the vocabulary, not the storage. +// +// WHY REFERENCE RANGES ARE HERE AT ALL, AND WHAT THEY ARE NOT. +// A number like "ferritin 42" means nothing on its own, so a tracker that +// stores bare numbers is a spreadsheet with extra steps. The ranges below let +// a value render as in or out of range and let a chart draw a band. +// +// They are NOT a diagnosis and NOT this app's opinion of your health. Every +// laboratory publishes its own reference interval, derived from its own assay +// and its own local population, and those differ enough that a value inside +// one lab's range can sit outside another's. **The range printed on your own +// report is the one that applies to you.** So each entry here is marked with +// where the interval came from, a value outside it is styled as "outside the +// typical range" rather than "high"/"abnormal", and nothing in the app ever +// tells you what to do about it. +// +// Units are fixed per marker rather than free-form, because a chart that mixes +// ng/mL and nmol/L on one axis is worse than no chart. A marker your lab +// reports in a different unit is a custom entry. + +import 'package:flutter/foundation.dart'; + +enum LabCategory { + blood, + iron, + metabolic, + lipids, + hormones, + vitamins, + inflammation, + organ, +} + +extension LabCategoryLabel on LabCategory { + String get label => switch (this) { + LabCategory.blood => 'Blood count', + LabCategory.iron => 'Iron', + LabCategory.metabolic => 'Metabolic', + LabCategory.lipids => 'Lipids', + LabCategory.hormones => 'Hormones', + LabCategory.vitamins => 'Vitamins & minerals', + LabCategory.inflammation => 'Inflammation', + LabCategory.organ => 'Liver & kidney', + }; +} + +/// Which reference interval applies. Several markers genuinely differ by sex, +/// and collapsing them to one range would mark a large share of normal results +/// as out of range — which is exactly the kind of false alarm that makes a +/// feature like this harmful rather than useful. +enum LabRefScope { any, male, female } + +@immutable +class LabRefRange { + const LabRefRange({ + required this.low, + required this.high, + this.scope = LabRefScope.any, + }); + + final double low; + final double high; + final LabRefScope scope; + + bool appliesTo(String? sex) => switch (scope) { + LabRefScope.any => true, + LabRefScope.male => sex == 'm' || sex == 'male', + LabRefScope.female => sex == 'f' || sex == 'female', + }; + + bool contains(double v) => v >= low && v <= high; +} + +@immutable +class LabMarker { + const LabMarker({ + required this.key, + required this.label, + required this.unit, + required this.category, + this.ranges = const [], + this.decimals = 1, + this.note, + this.custom = false, + }); + + /// Stable storage key. Labels can change; this cannot, or history orphans. + final String key; + final String label; + final String unit; + final LabCategory category; + + /// Typical adult reference intervals. Empty means the app shows the value + /// with no band at all rather than inventing one. + final List ranges; + final int decimals; + + /// Shown under the value when there is something the number alone hides. + final String? note; + final bool custom; + + /// The interval that applies to [sex], or null when none is defined for + /// them. Sex-specific entries win over an `any` entry. + LabRefRange? rangeFor(String? sex) { + LabRefRange? fallback; + for (final r in ranges) { + if (r.scope == LabRefScope.any) { + fallback ??= r; + } else if (r.appliesTo(sex)) { + return r; + } + } + return fallback; + } + + /// Whether [value] sits inside the applicable interval. Null when there is + /// no interval to judge against — which must render as "no opinion", never + /// as "fine". + bool? inRange(double value, {String? sex}) => rangeFor(sex)?.contains(value); + + String format(double v) => v.toStringAsFixed(decimals); + String formatWithUnit(double v) => '${format(v)} $unit'; +} + +/// Common adult panels. Intervals are widely published typical adult ranges; +/// they are a display aid, and the interval on your own report wins. +const kLabMarkers = [ + // ── Blood count ────────────────────────────────────────────────────────── + LabMarker( + key: 'hemoglobin', + label: 'Haemoglobin', + unit: 'g/dL', + category: LabCategory.blood, + ranges: [ + LabRefRange(low: 13.5, high: 17.5, scope: LabRefScope.male), + LabRefRange(low: 12.0, high: 15.5, scope: LabRefScope.female), + ], + ), + LabMarker( + key: 'hematocrit', + label: 'Haematocrit', + unit: '%', + category: LabCategory.blood, + ranges: [ + LabRefRange(low: 38.8, high: 50.0, scope: LabRefScope.male), + LabRefRange(low: 34.9, high: 44.5, scope: LabRefScope.female), + ], + ), + // ── Iron ───────────────────────────────────────────────────────────────── + LabMarker( + key: 'ferritin', + label: 'Ferritin', + unit: 'ng/mL', + category: LabCategory.iron, + decimals: 0, + ranges: [ + LabRefRange(low: 30, high: 400, scope: LabRefScope.male), + LabRefRange(low: 15, high: 200, scope: LabRefScope.female), + ], + note: 'Rises with inflammation, so a normal value does not by itself rule ' + 'out low iron stores.', + ), + LabMarker( + key: 'transferrin_saturation', + label: 'Transferrin saturation', + unit: '%', + category: LabCategory.iron, + decimals: 0, + ranges: [LabRefRange(low: 20, high: 50)], + ), + // ── Metabolic ──────────────────────────────────────────────────────────── + LabMarker( + key: 'hba1c', + label: 'HbA1c', + unit: '%', + category: LabCategory.metabolic, + ranges: [LabRefRange(low: 4.0, high: 5.6)], + note: 'Reflects roughly the last three months, not today.', + ), + LabMarker( + key: 'glucose_fasting', + label: 'Fasting glucose', + unit: 'mg/dL', + category: LabCategory.metabolic, + decimals: 0, + ranges: [LabRefRange(low: 70, high: 99)], + ), + LabMarker( + key: 'insulin_fasting', + label: 'Fasting insulin', + unit: 'µIU/mL', + category: LabCategory.metabolic, + ranges: [LabRefRange(low: 2.6, high: 24.9)], + ), + // ── Lipids ─────────────────────────────────────────────────────────────── + LabMarker( + key: 'cholesterol_total', + label: 'Total cholesterol', + unit: 'mg/dL', + category: LabCategory.lipids, + decimals: 0, + ranges: [LabRefRange(low: 0, high: 200)], + ), + LabMarker( + key: 'ldl', + label: 'LDL cholesterol', + unit: 'mg/dL', + category: LabCategory.lipids, + decimals: 0, + ranges: [LabRefRange(low: 0, high: 100)], + ), + LabMarker( + key: 'hdl', + label: 'HDL cholesterol', + unit: 'mg/dL', + category: LabCategory.lipids, + decimals: 0, + ranges: [ + LabRefRange(low: 40, high: 200, scope: LabRefScope.male), + LabRefRange(low: 50, high: 200, scope: LabRefScope.female), + ], + ), + LabMarker( + key: 'triglycerides', + label: 'Triglycerides', + unit: 'mg/dL', + category: LabCategory.lipids, + decimals: 0, + ranges: [LabRefRange(low: 0, high: 150)], + ), + LabMarker( + key: 'apob', + label: 'ApoB', + unit: 'mg/dL', + category: LabCategory.lipids, + decimals: 0, + ranges: [LabRefRange(low: 0, high: 90)], + ), + // ── Hormones ───────────────────────────────────────────────────────────── + LabMarker( + key: 'tsh', + label: 'TSH', + unit: 'mIU/L', + decimals: 2, + category: LabCategory.hormones, + ranges: [LabRefRange(low: 0.4, high: 4.0)], + ), + LabMarker( + key: 'free_t4', + label: 'Free T4', + unit: 'ng/dL', + decimals: 2, + category: LabCategory.hormones, + ranges: [LabRefRange(low: 0.8, high: 1.8)], + ), + LabMarker( + key: 'testosterone_total', + label: 'Total testosterone', + unit: 'ng/dL', + decimals: 0, + category: LabCategory.hormones, + ranges: [ + LabRefRange(low: 300, high: 1000, scope: LabRefScope.male), + LabRefRange(low: 15, high: 70, scope: LabRefScope.female), + ], + note: 'Varies through the day — morning draws are the comparable ones.', + ), + LabMarker( + key: 'cortisol_am', + label: 'Morning cortisol', + unit: 'µg/dL', + category: LabCategory.hormones, + ranges: [LabRefRange(low: 6, high: 23)], + ), + // ── Vitamins & minerals ────────────────────────────────────────────────── + LabMarker( + key: 'vitamin_d', + label: 'Vitamin D (25-OH)', + unit: 'ng/mL', + decimals: 0, + category: LabCategory.vitamins, + ranges: [LabRefRange(low: 30, high: 100)], + ), + LabMarker( + key: 'vitamin_b12', + label: 'Vitamin B12', + unit: 'pg/mL', + decimals: 0, + category: LabCategory.vitamins, + ranges: [LabRefRange(low: 200, high: 900)], + ), + LabMarker( + key: 'folate', + label: 'Folate', + unit: 'ng/mL', + category: LabCategory.vitamins, + ranges: [LabRefRange(low: 3.0, high: 20.0)], + ), + LabMarker( + key: 'magnesium', + label: 'Magnesium', + unit: 'mg/dL', + decimals: 2, + category: LabCategory.vitamins, + ranges: [LabRefRange(low: 1.7, high: 2.2)], + ), + // ── Inflammation ───────────────────────────────────────────────────────── + LabMarker( + key: 'crp_hs', + label: 'hs-CRP', + unit: 'mg/L', + decimals: 2, + category: LabCategory.inflammation, + ranges: [LabRefRange(low: 0, high: 3.0)], + note: 'Any recent infection or hard training block can lift this.', + ), + // ── Liver & kidney ─────────────────────────────────────────────────────── + LabMarker( + key: 'alt', + label: 'ALT', + unit: 'U/L', + decimals: 0, + category: LabCategory.organ, + ranges: [ + LabRefRange(low: 7, high: 55, scope: LabRefScope.male), + LabRefRange(low: 7, high: 45, scope: LabRefScope.female), + ], + ), + LabMarker( + key: 'ast', + label: 'AST', + unit: 'U/L', + decimals: 0, + category: LabCategory.organ, + ranges: [LabRefRange(low: 8, high: 48)], + ), + LabMarker( + key: 'creatinine', + label: 'Creatinine', + unit: 'mg/dL', + decimals: 2, + category: LabCategory.organ, + ranges: [ + LabRefRange(low: 0.74, high: 1.35, scope: LabRefScope.male), + LabRefRange(low: 0.59, high: 1.04, scope: LabRefScope.female), + ], + note: 'Muscle mass lifts this, so it reads high in some athletes without ' + 'anything being wrong.', + ), + LabMarker( + key: 'egfr', + label: 'eGFR', + unit: 'mL/min/1.73m²', + decimals: 0, + category: LabCategory.organ, + ranges: [LabRefRange(low: 90, high: 200)], + ), +]; + +final Map kLabMarkersByKey = { + for (final m in kLabMarkers) m.key: m, +}; + +/// Markers grouped for the picker, in [LabCategory] declaration order. +Map> labMarkersByCategory() { + final out = >{}; + for (final c in LabCategory.values) { + final markers = kLabMarkers.where((m) => m.category == c).toList(); + if (markers.isNotEmpty) out[c] = markers; + } + return out; +} + +/// Resolve a stored key, preferring the shipped catalogue so a future built-in +/// always wins over a same-named custom (otherwise one key would mean two +/// different things on two installs). +LabMarker? labMarker(String key, {List custom = const []}) { + final builtIn = kLabMarkersByKey[key]; + if (builtIn != null) return builtIn; + for (final c in custom) { + if (c.key == key) return c; + } + return null; +} + +/// Storage key for a user-defined marker. Prefixed so it can never collide +/// with a catalogue key added in a later release. +String customLabMarkerKey(String label) { + final slug = label + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '_') + .replaceAll(RegExp(r'^_+|_+$'), ''); + return 'custom_$slug'; +} diff --git a/lib/ui/labs/lab_entry_sheet.dart b/lib/ui/labs/lab_entry_sheet.dart new file mode 100644 index 0000000..194a7d9 --- /dev/null +++ b/lib/ui/labs/lab_entry_sheet.dart @@ -0,0 +1,414 @@ +// Add or edit one lab result. +// +// The unit is NOT editable for a catalogue marker. Free-form units are how a +// series ends up with ferritin in ng/mL on one row and nmol/L on the next, and +// a chart drawn across both is worse than no chart. A marker your lab reports +// differently is a custom marker, with its own unit, kept as its own series. + +import 'package:flutter/material.dart'; + +import '../../data/day_label.dart'; +import '../../data/db.dart'; +import '../../data/lab_catalogue.dart'; +import '../design/design.dart'; + +/// Returns true when something was saved or deleted. +Future showLabEntrySheet( + BuildContext context, { + required List custom, + Map? existing, +}) async { + final saved = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(ctx).bottom), + child: _LabEntrySheet(custom: custom, existing: existing), + ), + ); + return saved ?? false; +} + +class _LabEntrySheet extends StatefulWidget { + const _LabEntrySheet({required this.custom, this.existing}); + final List custom; + final Map? existing; + + @override + State<_LabEntrySheet> createState() => _LabEntrySheetState(); +} + +class _LabEntrySheetState extends State<_LabEntrySheet> { + final _valueCtrl = TextEditingController(); + final _noteCtrl = TextEditingController(); + final _customNameCtrl = TextEditingController(); + final _customUnitCtrl = TextEditingController(); + + String? _markerKey; + bool _definingCustom = false; + late DateTime _takenOn; + String? _error; + + bool get _isEdit => widget.existing != null; + + @override + void initState() { + super.initState(); + final e = widget.existing; + if (e != null) { + _markerKey = e['marker'] as String; + _valueCtrl.text = (e['value'] as num).toString(); + _noteCtrl.text = (e['note'] as String?) ?? ''; + _takenOn = DateTime.parse(e['taken_on'] as String); + } else { + _takenOn = DateTime.now(); + } + } + + @override + void dispose() { + _valueCtrl.dispose(); + _noteCtrl.dispose(); + _customNameCtrl.dispose(); + _customUnitCtrl.dispose(); + super.dispose(); + } + + // The one local-day-label formatter, shared with every other layer. + String get _dateLabel => dayLabelOf(_takenOn); + + Future _pickDate() async { + final now = DateTime.now(); + // Blood cannot be drawn in the future, and a decade covers any history + // worth typing in by hand. + final first = DateTime(now.year - 10); + // showDatePicker ASSERTS that initialDate sits inside the bounds, so a row + // older than the window — or an imported one dated in the future — would + // throw instead of opening the picker. + final initial = _takenOn.isBefore(first) + ? first + : (_takenOn.isAfter(now) ? now : _takenOn); + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: first, + lastDate: now, + ); + if (picked != null && mounted) setState(() => _takenOn = picked); + } + + Future _save() async { + final key = _markerKey; + if (key == null) { + setState(() => _error = 'Pick a marker'); + return; + } + final value = double.tryParse(_valueCtrl.text.trim().replaceAll(',', '.')); + if (value == null) { + setState(() => _error = 'Enter the number from your report'); + return; + } + final marker = labMarker(key, custom: widget.custom); + await LocalDb.putLabResult( + marker: key, + takenOn: _dateLabel, + value: value, + // Stored per row so the reading keeps the unit it was entered under, + // even if the catalogue's canonical unit changes later. Editing an + // existing row therefore keeps ITS unit rather than adopting whatever + // the definition says now — and a row whose definition has been deleted + // keeps its unit instead of being blanked. + unit: marker?.unit ?? (widget.existing?['unit'] as String?) ?? '', + note: _noteCtrl.text.trim(), + ); + if (mounted) Navigator.pop(context, true); + } + + Future _delete() async { + final e = widget.existing; + if (e == null) return; + await LocalDb.deleteLabResult(e['marker'] as String, e['taken_on'] as String); + if (mounted) Navigator.pop(context, true); + } + + Future _saveCustomMarker() async { + final label = _customNameCtrl.text.trim(); + final unit = _customUnitCtrl.text.trim(); + if (label.isEmpty || unit.isEmpty) { + setState(() => _error = 'A name and a unit, both from your report'); + return; + } + final key = customLabMarkerKey(label); + if (key == 'custom_') { + setState(() => _error = 'Use at least one letter or number'); + return; + } + // "Lp(a)", "Lp a" and "LP-A" all slug to the same key, and the write + // replaces on key — so without this the second one silently overwrites the + // first's unit, and every reading already stored under it starts rendering + // in a unit it was never measured in. + final clash = widget.custom.where((m) => m.key == key).firstOrNull; + if (clash != null && (clash.label != label || clash.unit != unit)) { + setState( + () => _error = 'You already track "${clash.label}" (${clash.unit})', + ); + return; + } + await LocalDb.putLabMarkerDef({ + 'key': key, + 'label': label, + 'unit': unit, + 'category': LabCategory.blood.name, + 'decimals': 2, + // No reference range: inventing one for a marker the app knows nothing + // about is exactly the kind of false verdict this feature must avoid. + 'ref_low': null, + 'ref_high': null, + }); + if (!mounted) return; + setState(() { + _definingCustom = false; + _markerKey = key; + _error = null; + }); + } + + @override + Widget build(BuildContext context) { + final marker = _markerKey == null + ? null + : labMarker(_markerKey!, custom: widget.custom); + return SafeArea( + top: false, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.85, + ), + child: Padding( + padding: const EdgeInsets.all(Sp.x5), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + _isEdit ? 'Edit result' : 'Add a result', + style: AppText.h2, + ), + ), + if (_isEdit) + Semantics( + button: true, + label: 'Delete this result', + child: Pressable( + pressedScale: 0.9, + onTap: _delete, + child: Icon( + Icons.delete_outline_rounded, + size: 20, + color: AppColors.inkSoft, + ), + ), + ), + ], + ), + const SizedBox(height: Sp.x4), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_definingCustom) + ..._customFields() + else ...[ + if (!_isEdit) ..._markerPicker(), + if (marker != null) + ..._valueFields(marker) + // An edit whose definition has since been deleted must + // still be editable — its own row carries the unit. + else if (_isEdit) + ..._valueFieldsForDeletedMarker(), + ], + if (_error != null) ...[ + const SizedBox(height: Sp.x3), + Text( + _error!, + style: AppText.label.copyWith(color: AppColors.bad), + ), + ], + ], + ), + ), + ), + const SizedBox(height: Sp.x4), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _definingCustom ? _saveCustomMarker : _save, + child: Text(_definingCustom ? 'Add marker' : 'Save'), + ), + ), + ], + ), + ), + ), + ); + } + + List _markerPicker() => [ + for (final entry in _groupedForPicker().entries) ...[ + Padding( + padding: const EdgeInsets.only(bottom: Sp.x2, top: Sp.x2), + child: Text( + entry.key, + style: AppText.label.copyWith(color: AppColors.inkSoft), + ), + ), + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + children: [ + for (final m in entry.value) + ToggleChip( + m.label, + selected: _markerKey == m.key, + onTap: () => setState(() { + _markerKey = m.key; + _error = null; + }), + ), + ], + ), + ], + const SizedBox(height: Sp.x3), + Pressable( + pressedScale: 0.96, + onTap: () => setState(() { + _definingCustom = true; + _error = null; + }), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_rounded, size: 18, color: AppColors.accent), + const SizedBox(width: Sp.x1), + Text( + 'Something else', + style: AppText.label.copyWith(color: AppColors.accent), + ), + ], + ), + ), + ]; + + Map> _groupedForPicker() { + final out = >{}; + for (final e in labMarkersByCategory().entries) { + out[e.key.label] = e.value; + } + if (widget.custom.isNotEmpty) out['Yours'] = widget.custom; + return out; + } + + List _valueFields(LabMarker marker) => [ + const SizedBox(height: Sp.x4), + Text( + marker.label, + style: AppText.label.copyWith(color: AppColors.inkSoft), + ), + const SizedBox(height: Sp.x2), + Row( + children: [ + Expanded( + child: TextField( + controller: _valueCtrl, + autofocus: !_isEdit, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: AppText.body, + decoration: _fieldDecoration('Value'), + ), + ), + const SizedBox(width: Sp.x3), + // Fixed, not editable: a series that mixes units is unchartable. + Text(marker.unit, style: AppText.body.copyWith( + color: AppColors.inkSoft, + )), + ], + ), + const SizedBox(height: Sp.x4), + Text('Date drawn', style: AppText.label.copyWith(color: AppColors.inkSoft)), + const SizedBox(height: Sp.x2), + Pressable( + pressedScale: 0.96, + onTap: _pickDate, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: Sp.x4, + vertical: Sp.x3, + ), + decoration: BoxDecoration( + color: AppColors.surfaceAlt, + borderRadius: BorderRadius.circular(R.cardSm), + ), + child: Text(_dateLabel, style: AppText.body), + ), + ), + const SizedBox(height: Sp.x4), + TextField( + controller: _noteCtrl, + style: AppText.body, + decoration: _fieldDecoration('Note (optional)'), + ), + ]; + + /// Edit fields for a row whose marker definition no longer exists. The row's + /// own `unit` is the authority, so it stays fully editable rather than + /// becoming a value nobody can correct. + List _valueFieldsForDeletedMarker() => _valueFields( + LabMarker( + key: _markerKey ?? '', + label: _markerKey ?? 'Result', + unit: (widget.existing?['unit'] as String?) ?? '', + category: LabCategory.blood, + decimals: 2, + custom: true, + ), + ); + + List _customFields() => [ + Text( + 'Add a marker this app does not know about. It is stored with the unit ' + 'you give it and shown without a reference range — the app will not ' + 'invent one.', + style: AppText.caption.copyWith(color: AppColors.inkMuted), + ), + const SizedBox(height: Sp.x4), + TextField( + controller: _customNameCtrl, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + style: AppText.body, + decoration: _fieldDecoration('Name, e.g. Lp(a)'), + ), + const SizedBox(height: Sp.x3), + TextField( + controller: _customUnitCtrl, + style: AppText.body, + decoration: _fieldDecoration('Unit, e.g. nmol/L'), + ), + ]; + + InputDecoration _fieldDecoration(String hint) => InputDecoration( + hintText: hint, + hintStyle: AppText.bodySoft.copyWith(color: AppColors.inkMuted), + filled: true, + fillColor: AppColors.surfaceAlt, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(R.cardSm), + borderSide: BorderSide.none, + ), + ); +} diff --git a/lib/ui/labs/labs_screen.dart b/lib/ui/labs/labs_screen.dart new file mode 100644 index 0000000..e0c9ec2 --- /dev/null +++ b/lib/ui/labs/labs_screen.dart @@ -0,0 +1,321 @@ +// Labs — hand-entered blood work, with a trend per marker. +// +// The band cannot measure any of this. That is the point: a ferritin or an +// HbA1c is the context that makes a year of resting-HR drift mean something, +// and it is the one health record people already have and cannot keep anywhere +// local. +// +// The honesty line this screen holds: a value is shown against a TYPICAL adult +// reference interval, never against a verdict. Out-of-range reads as "outside +// the typical range", never "high" or "abnormal", because every laboratory +// publishes its own interval and the one on the user's own report is the one +// that applies to them. The screen never suggests what to do about a value. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../data/db.dart'; +import '../../data/lab_catalogue.dart'; +import '../../state/app_state.dart'; +import '../design/design.dart'; +import 'lab_entry_sheet.dart'; + +class LabsScreen extends StatefulWidget { + const LabsScreen({super.key}); + @override + State createState() => _LabsScreenState(); +} + +class _LabsScreenState extends State { + bool _loading = true; + List> _results = const []; + List _custom = const []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + final rows = await LocalDb.labResults(); + final defs = await LocalDb.labMarkerDefs(); + if (!mounted) return; + setState(() { + _results = rows; + _custom = [ + for (final d in defs) + LabMarker( + key: d['key'] as String, + label: d['label'] as String, + unit: d['unit'] as String, + category: LabCategory.values.firstWhere( + (c) => c.name == d['category'], + orElse: () => LabCategory.blood, + ), + decimals: (d['decimals'] as num?)?.toInt() ?? 1, + ranges: [ + if (d['ref_low'] != null && d['ref_high'] != null) + LabRefRange( + low: (d['ref_low'] as num).toDouble(), + high: (d['ref_high'] as num).toDouble(), + ), + ], + custom: true, + ), + ]; + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + + Future _add() async { + if (await showLabEntrySheet(context, custom: _custom) && mounted) { + await _load(); + } + } + + Future _edit(Map row) async { + final ok = await showLabEntrySheet( + context, + custom: _custom, + existing: row, + ); + if (ok && mounted) await _load(); + } + + /// Results grouped by marker, newest draw first within each. + Map>> get _byMarker { + final out = >>{}; + for (final r in _results) { + (out[r['marker'] as String] ??= []).add(r); + } + return out; + } + + @override + Widget build(BuildContext context) { + final grouped = _byMarker; + // Watched, not read: setting a sex in Profile and coming back here has to + // re-resolve the reference intervals, and several markers show none at all + // until it is set. `read` would have left them resolved against the old + // value until something else happened to rebuild this screen. + final sex = context + .select((a) => a.user?['sex'] as String?) + ?.toLowerCase(); + return AppScaffold( + title: 'Labs', + actions: [ + Semantics( + button: true, + label: 'Add a lab result', + child: Pressable( + pressedScale: 0.94, + onTap: _add, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Sp.x4, + vertical: Sp.x3, + ), + decoration: BoxDecoration( + color: AppColors.tonalFill(AppColors.accent), + borderRadius: BorderRadius.circular(R.pill), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_rounded, size: 18, color: AppColors.accent), + const SizedBox(width: Sp.x1), + Text( + 'Add', + style: AppText.label.copyWith(color: AppColors.accent), + ), + ], + ), + ), + ), + ), + ], + body: RefreshIndicator( + onRefresh: _load, + color: AppColors.accent, + child: ListView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + padding: EdgeInsets.fromLTRB( + Sp.screen, + Sp.x2, + Sp.screen, + dsBottomGutter(context), + ), + children: [ + if (_loading) + Padding( + padding: const EdgeInsets.symmetric(vertical: Sp.x4), + child: Skeleton.tileRow(rows: 3), + ) + else if (grouped.isEmpty) + StateCard( + icon: OsIcon.ecgRhythm, + title: 'No results yet', + message: 'Add a blood test and it stays on this phone, next to ' + 'everything the band measures.', + actionLabel: 'Add a result', + onAction: _add, + ) + else ...[ + for (final entry in grouped.entries) + Padding( + padding: const EdgeInsets.only(bottom: Sp.x3), + child: _MarkerCard( + marker: labMarker(entry.key, custom: _custom), + fallbackKey: entry.key, + results: entry.value, + sex: sex, + onTapResult: _edit, + ), + ), + const SizedBox(height: Sp.x3), + Text( + 'Ranges shown are typical adult reference intervals, for ' + 'context only. Every laboratory publishes its own, and the one ' + 'printed on your report is the one that applies to you.', + style: AppText.caption.copyWith(color: AppColors.inkMuted), + ), + ], + ], + ), + ), + ); + } +} + +class _MarkerCard extends StatelessWidget { + const _MarkerCard({ + required this.marker, + required this.fallbackKey, + required this.results, + required this.sex, + required this.onTapResult, + }); + + /// Null when the definition is gone but its results remain — a deleted + /// custom marker. Those still render, from the unit stored on each row. + final LabMarker? marker; + final String fallbackKey; + final List> results; + final String? sex; + final ValueChanged> onTapResult; + + @override + Widget build(BuildContext context) { + final m = marker; + final latest = results.first; + final value = (latest['value'] as num).toDouble(); + final unit = latest['unit'] as String; + final inRange = m?.inRange(value, sex: sex); + final range = m?.rangeFor(sex); + + return SurfaceCard( + padding: const EdgeInsets.all(Sp.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text(m?.label ?? fallbackKey, style: AppText.title), + ), + Text( + m == null ? '$value $unit' : '${m.format(value)} $unit', + style: AppText.title.copyWith( + // No colour at all when there is no interval to judge + // against — a neutral number, not a quiet reassurance. + color: inRange == null + ? AppColors.ink + : (inRange ? AppColors.good : AppColors.warn), + ), + ), + ], + ), + const SizedBox(height: Sp.x1), + Row( + children: [ + Expanded( + child: Text( + latest['taken_on'] as String, + style: AppText.caption.copyWith(color: AppColors.inkMuted), + ), + ), + if (range != null) + Text( + inRange == true + ? 'within the typical range' + : 'outside the typical range', + style: AppText.caption.copyWith(color: AppColors.inkMuted), + ), + ], + ), + if (m?.note != null) ...[ + const SizedBox(height: Sp.x2), + Text( + m!.note!, + style: AppText.caption.copyWith(color: AppColors.inkMuted), + ), + ], + if (results.length > 1) ...[ + const SizedBox(height: Sp.x3), + const Divider(height: 1), + const SizedBox(height: Sp.x2), + for (final r in results) + Pressable( + pressedScale: 0.98, + onTap: () => onTapResult(r), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Sp.x1), + child: Row( + children: [ + Expanded( + child: Text( + r['taken_on'] as String, + style: AppText.bodySoft, + ), + ), + Text( + // The row's own unit, always — the definition may have + // changed its canonical unit since this was entered, + // and only the row knows what was measured. + m == null + ? '${r['value']} ${r['unit']}' + : '${m.format((r['value'] as num).toDouble())} ' + '${r['unit']}', + style: AppText.body, + ), + ], + ), + ), + ), + ] else + Padding( + padding: const EdgeInsets.only(top: Sp.x2), + child: Pressable( + pressedScale: 0.96, + onTap: () => onTapResult(latest), + child: Text( + 'Edit', + style: AppText.label.copyWith(color: AppColors.accent), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index c1eac89..2bbb10d 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -32,11 +32,21 @@ import '../import/import_screen.dart'; import '../today/step_goal_screen.dart'; import 'about_screen.dart'; import 'advanced_data_screen.dart'; +import '../../data/csv_export.dart'; +import '../labs/labs_screen.dart'; import 'data_history_screen.dart'; import 'gesture_section.dart'; import 'notification_relay_section.dart'; import 'notification_settings_screen.dart'; +/// True while a CSV export is being handed to the share sheet. +/// +/// File-scoped rather than widget state on purpose: the resource being guarded +/// is the export directory on disk, which is global, and `ProfileScreen` is +/// stateless and rebuilt freely. Two Profile screens on the navigation stack +/// must not be able to clean up each other's in-flight export. +bool _csvExportInFlight = false; + class ProfileScreen extends StatelessWidget { const ProfileScreen({super.key}); @@ -243,6 +253,76 @@ class ProfileScreen extends StatelessWidget { divider: true, ), ), + // Blood work lives here rather than on a daily screen: it is a + // record you consult, not a number that changes overnight. + ListRow( + icon: OsIcon.ecgRhythm, + title: 'Labs', + value: 'Blood work', + divider: true, + onTap: () => Navigator.of(context).push( + themedRoute((_) => const LabsScreen(), name: 'LabsScreen'), + ), + ), + // CSV alongside the .db export: "open it in a spreadsheet" and + // "restore it onto another phone" are different jobs, and a SQLite + // file only does the second. + Builder( + builder: (rowCtx) => ListRow( + icon: OsIcon.share, + title: 'Export data (.csv)', + value: 'Share', + onTap: () async { + // A second export while the first share sheet is still open + // would start cleaning up run directories underneath it. The + // share is awaited, so this flag covers exactly that window. + if (_csvExportInFlight) return; + _csvExportInFlight = true; + final messenger = ScaffoldMessenger.of(rowCtx); + final origin = shareOriginFor(rowCtx); + try { + final result = await exportCsvFiles(kCsvExportSets); + if (!rowCtx.mounted) return; + if (result.isEmpty) { + // "Nothing to export" and "the export broke" are + // different answers and must not share a message. + messenger.showSnackBar( + SnackBar( + content: Text( + result.hasFailures + ? 'Export failed' + : 'Nothing to export yet', + ), + ), + ); + return; + } + if (result.hasFailures) { + messenger.showSnackBar( + SnackBar( + content: Text( + 'Exported all but ${result.failed.join(', ')}', + ), + ), + ); + } + await Share.shareXFiles( + [for (final p in result.paths) XFile(p)], + text: 'OpenStrap CSV export', + sharePositionOrigin: origin, + ); + } catch (e) { + if (!rowCtx.mounted) return; + messenger.showSnackBar( + SnackBar(content: Text('Export failed: $e')), + ); + } finally { + _csvExportInFlight = false; + } + }, + divider: true, + ), + ), // Per-day data manager (browse, export, delete stored days). ListRow( icon: OsIcon.history, diff --git a/test/csv_export_test.dart b/test/csv_export_test.dart new file mode 100644 index 0000000..4ec8f5d --- /dev/null +++ b/test/csv_export_test.dart @@ -0,0 +1,291 @@ +// CSV export — escaping, absence, and that every query actually runs. +// +// The escaping half is ordinary RFC 4180. The half worth caring about is that +// a null becomes an EMPTY field: nobody can recover "not measured" from a 0 +// once the file is in a spreadsheet, and a column of zeroes where a metric was +// never computed is a fabrication the user will then average. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/csv_export.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this.root); + final String root; + @override + Future getTemporaryPath() async => root; + @override + Future getApplicationSupportPath() async => root; + @override + Future getApplicationDocumentsPath() async => root; + @override + Future getApplicationCachePath() async => root; + @override + Future getLibraryPath() async => root; + @override + Future getDownloadsPath() async => root; +} + +void main() { + group('csvField', () { + test('leaves a plain value alone', () { + expect(csvField('run'), 'run'); + expect(csvField(42), '42'); + }); + + test('an absent value is an empty field, not a zero and not "null"', () { + expect(csvField(null), ''); + expect(csvField(null), isNot('0')); + expect(csvField(null), isNot('null')); + }); + + test('a real zero still prints as zero', () { + // Absence and zero have to stay distinguishable in the file too. + expect(csvField(0), '0'); + expect(csvField(0.0), '0'); + }); + + test('a whole double drops its decimal', () { + // "55.0" in a resting-HR column implies a precision the metric does not + // have. + expect(csvField(55.0), '55'); + expect(csvField(55.5), '55.5'); + }); + + test('quotes a field containing a comma, quote or newline', () { + expect(csvField('felt rough, slept badly'), '"felt rough, slept badly"'); + expect(csvField('he said "fine"'), '"he said ""fine"""'); + expect(csvField('line one\nline two'), '"line one\nline two"'); + expect(csvField('carriage\rreturn'), '"carriage\rreturn"'); + }); + }); + + group('renderCsv', () { + test('writes a header and one line per row', () { + final out = renderCsv( + ['date', 'rhr'], + [ + {'date': '2026-06-01', 'rhr': 52}, + {'date': '2026-06-02', 'rhr': 54}, + ], + ); + expect(out.trim().split('\n'), [ + 'date,rhr', + '2026-06-01,52', + '2026-06-02,54', + ]); + }); + + test('a column missing from a row is empty, not dropped', () { + // Every line must have the same field count or the file will not parse. + final out = renderCsv( + ['date', 'rhr', 'hrv'], + [ + {'date': '2026-06-01', 'rhr': 52}, + ], + ); + expect(out.trim().split('\n').last, '2026-06-01,52,'); + expect(out.trim().split('\n').last.split(',').length, 3); + }); + + test('no rows still produces a usable header', () { + expect(renderCsv(['date', 'rhr'], const []).trim(), 'date,rhr'); + }); + }); + + group('the export sets themselves', () { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_csv_export_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + tearDownAll(() async => LocalDb.close()); + + test('every set has a unique name and a non-empty header', () { + final names = kCsvExportSets.map((s) => s.name).toList(); + expect(names.toSet().length, names.length); + for (final s in kCsvExportSets) { + expect(s.columns, isNotEmpty); + expect(s.columns.toSet().length, s.columns.length, + reason: '${s.name} has a duplicate column'); + } + }); + + test('every query parses and runs against the real schema', () async { + // The failure mode this guards: a view gets renamed or loses a column, + // and the export quietly produces a file of empty fields. SQLite throws + // on an unknown column or table, so simply running each one is the + // check — it is the declared-columns test below that catches a header + // drifting away from what the query returns. + final db = await LocalDb.instance; + for (final s in kCsvExportSets) { + await expectLater( + db.rawQuery(s.sql), + completes, + reason: '${s.name} does not run against the current schema', + ); + } + }); + + test('a daily row comes back under the declared column names', () async { + final db = await LocalDb.instance; + await db.insert('metric_series', { + 'date': '2026-06-01', + 'key': 'rhr', + 'value': 52.0, + }); + await db.insert('metric_series', { + 'date': '2026-06-01', + 'key': 'readiness', + 'value': 71.0, + }); + + final daily = kCsvExportSets.firstWhere((s) => s.name == 'daily'); + final rows = await db.rawQuery(daily.sql); + expect(rows, hasLength(1)); + for (final c in daily.columns) { + expect(rows.first.containsKey(c), isTrue, + reason: '"$c" is declared but the query does not return it'); + } + expect(rows.first['resting_hr'], 52.0); + expect(rows.first['readiness'], 71.0); + + // And a metric that was never computed stays absent all the way into the + // rendered file. + final csv = renderCsv(daily.columns, rows); + final line = csv.trim().split('\n').last.split(','); + expect(line[daily.columns.indexOf('hrv')], ''); + expect(line[daily.columns.indexOf('resting_hr')], '52'); + }); + }); + + group('formula injection', () { + test('neutralises a text cell a spreadsheet would execute', () { + // These files go through a share sheet, so whoever opens the spreadsheet + // runs whatever the cell evaluates to. + for (final leader in ['=', '+', '-', '@', '\t', '\r']) { + final out = csvField('${leader}cmd|calc'); + expect(out.startsWith("'") || out.startsWith('"\''), isTrue, + reason: 'a cell starting "$leader" was left executable'); + } + }); + + test('a negative number stays a number', () { + // Only strings are prefixed. Quoting every negative delta in the file + // would make the numeric columns unusable. + expect(csvField(-5), '-5'); + expect(csvField(-5.5), '-5.5'); + expect(csvField(-5.0), '-5'); + }); + + test('ordinary text is untouched', () { + expect(csvField('felt rough'), 'felt rough'); + expect(csvField('2026-06-01'), '2026-06-01'); + }); + }); + + group('exportCsvFiles', () { + late Directory tmp; + + setUpAll(() async { + tmp = await Directory.systemTemp.createTemp('openstrap_csv_files_'); + PathProviderPlatform.instance = _FakePathProvider(tmp.path); + }); + + tearDownAll(() async { + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + test('writes a BOM, skips empty sets, and reports failures', () async { + final db = await LocalDb.instance; + await db.insert('metric_series', { + 'date': '2026-07-01', + 'key': 'rhr', + 'value': 51.0, + }); + + const broken = CsvExportSet( + name: 'broken', + title: 'Broken', + columns: ['x'], + sql: 'SELECT x FROM a_table_that_does_not_exist', + ); + final daily = kCsvExportSets.firstWhere((s) => s.name == 'daily'); + final labs = kCsvExportSets.firstWhere((s) => s.name == 'labs'); + + // Explicit stamps throughout this group: run directories are named by + // timestamp and pruned newest-first, so a `now()` default here would + // outrank the fixed dates the later tests use. + final result = await exportCsvFiles( + [daily, labs, broken], + now: DateTime(2026, 1, 1), + ); + + expect(result.paths, hasLength(1), reason: 'labs is empty, so no file'); + expect(result.failed, ['broken']); + expect(result.hasFailures, isTrue); + expect(result.isEmpty, isFalse); + + final bytes = await File(result.paths.single).readAsBytes(); + expect(bytes.take(3), [0xEF, 0xBB, 0xBF], + reason: 'without the BOM, Excel on Windows mangles every note'); + }); + + test('an export does not delete the previous run out from under a share', + () async { + // exportCsvFiles returns BEFORE the caller finishes handing the files to + // a share sheet, and the share target reads them lazily. A run that + // wiped every earlier run would delete files a still-open share session + // was about to read. + final daily = kCsvExportSets.firstWhere((s) => s.name == 'daily'); + final first = await exportCsvFiles([daily], now: DateTime(2026, 7, 1)); + final second = await exportCsvFiles([daily], now: DateTime(2026, 7, 2)); + + expect(File(first.paths.single).existsSync(), isTrue, + reason: 'the previous run must survive the next one starting'); + expect(File(second.paths.single).existsSync(), isTrue); + }); + + test('copies stay bounded rather than accumulating forever', () async { + // The other half of the trade: these are plaintext health files, so old + // runs are still cleaned up — just not the one that may still be in use. + final daily = kCsvExportSets.firstWhere((s) => s.name == 'daily'); + final oldest = await exportCsvFiles([daily], now: DateTime(2026, 8, 1)); + await exportCsvFiles([daily], now: DateTime(2026, 8, 2)); + final newest = await exportCsvFiles([daily], now: DateTime(2026, 8, 3)); + + expect(File(oldest.paths.single).existsSync(), isFalse); + expect(File(newest.paths.single).existsSync(), isTrue); + + final parent = Directory(p.dirname(p.dirname(newest.paths.single))); + expect(parent.listSync().whereType(), hasLength(2)); + }); + + test('nothing to export is not the same as everything failing', () async { + const broken = CsvExportSet( + name: 'broken', + title: 'Broken', + columns: ['x'], + sql: 'SELECT x FROM nope', + ); + final labs = kCsvExportSets.firstWhere((s) => s.name == 'labs'); + + final empty = await exportCsvFiles([labs], now: DateTime(2026, 9, 1)); + expect(empty.isEmpty, isTrue); + expect(empty.hasFailures, isFalse); + + final failed = await exportCsvFiles([broken], now: DateTime(2026, 9, 2)); + expect(failed.isEmpty, isTrue); + expect(failed.hasFailures, isTrue); + }); + }); +} diff --git a/test/db_migration_ladder_test.dart b/test/db_migration_ladder_test.dart index 4ab13dc..f4a6718 100644 --- a/test/db_migration_ladder_test.dart +++ b/test/db_migration_ladder_test.dart @@ -344,4 +344,30 @@ void main() { expect(health['ok'], isTrue, reason: '$health'); }, ); + + + test( + 'upgrade from v27 creates the lab tables and they accept a write ' + 'immediately, not on the next launch', + () async { + const name = 'migrate_from_v27_labs_test.db'; + created.add(name); + await _seedOldDb(name, 27, _v5DerivedDdl); + + final version = await _openThroughLocalDb(name); + expect(version, LocalDb.schemaVersion); + + await LocalDb.putLabResult( + marker: 'ferritin', + takenOn: '2026-03-04', + value: 42, + unit: 'ng/mL', + ); + expect((await LocalDb.labResults()).single['value'], 42.0); + expect(await LocalDb.labMarkerDefs(), isEmpty); + + final health = await LocalDb.schemaHealth(); + expect(health['ok'], isTrue, reason: '$health'); + }, + ); } diff --git a/test/lab_catalogue_test.dart b/test/lab_catalogue_test.dart new file mode 100644 index 0000000..ddbd95c --- /dev/null +++ b/test/lab_catalogue_test.dart @@ -0,0 +1,180 @@ +// The lab marker catalogue. +// +// Reference ranges are the part that can do harm here. A range that is wrong, +// or applied to the wrong person, turns a tracker into a source of false +// alarms — so these tests are mostly about the ranges being coherent, applied +// to the right sex, and absent rather than guessed when there isn't one. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/lab_catalogue.dart'; + +void main() { + group('the catalogue', () { + test('keys are unique, lowercase, and outside the custom namespace', () { + final keys = kLabMarkers.map((m) => m.key).toList(); + expect(keys.toSet().length, keys.length, reason: 'duplicate marker key'); + for (final k in keys) { + expect(k, k.toLowerCase()); + expect(k, isNot(startsWith('custom_'))); + } + }); + + test('the by-key index matches the list', () { + expect(kLabMarkersByKey.length, kLabMarkers.length); + for (final m in kLabMarkers) { + expect(kLabMarkersByKey[m.key], same(m)); + } + }); + + test('every marker has a unit and a sane precision', () { + for (final m in kLabMarkers) { + expect(m.unit, isNotEmpty, reason: '${m.key} has no unit'); + expect(m.decimals, inInclusiveRange(0, 3)); + } + }); + + test('every range is ordered and non-degenerate', () { + for (final m in kLabMarkers) { + for (final r in m.ranges) { + expect(r.low, lessThan(r.high), + reason: '${m.key} has a range that excludes everything'); + } + } + }); + + test('a marker never defines two ranges for the same sex', () { + // Two competing intervals for one person is a silent coin flip over + // which one the value is judged against. + for (final m in kLabMarkers) { + final scopes = m.ranges.map((r) => r.scope).toList(); + expect(scopes.toSet().length, scopes.length, reason: m.key); + } + }); + + test('a sex-scoped marker covers both sexes, not just one', () { + // Half a definition is worse than none: everyone of the uncovered sex + // silently gets no verdict while everyone else gets one. + for (final m in kLabMarkers) { + final scoped = m.ranges.where((r) => r.scope != LabRefScope.any); + if (scoped.isEmpty) continue; + if (m.ranges.any((r) => r.scope == LabRefScope.any)) continue; + expect( + scoped.map((r) => r.scope).toSet(), + {LabRefScope.male, LabRefScope.female}, + reason: '${m.key} defines a range for one sex only', + ); + } + }); + + test('grouping covers every marker exactly once', () { + final grouped = labMarkersByCategory().values + .expand((e) => e) + .map((m) => m.key) + .toList(); + expect(grouped.toSet(), kLabMarkers.map((m) => m.key).toSet()); + expect(grouped.length, kLabMarkers.length); + }); + }); + + group('range selection', () { + final ferritin = kLabMarkersByKey['ferritin']!; + final hba1c = kLabMarkersByKey['hba1c']!; + + test('picks the interval for the reader’s sex', () { + expect(ferritin.rangeFor('m')!.low, 30); + expect(ferritin.rangeFor('f')!.low, 15); + expect(ferritin.rangeFor('female')!.high, 200); + }); + + test('a sex-specific marker gives no verdict without a sex', () { + // Guessing one would mark a large share of normal results as out of + // range, which is the exact false alarm this feature must not create. + expect(ferritin.rangeFor(null), isNull); + expect(ferritin.inRange(20, sex: null), isNull); + }); + + test('an unscoped marker applies to everyone', () { + expect(hba1c.rangeFor(null), isNotNull); + expect(hba1c.inRange(5.2), isTrue); + expect(hba1c.inRange(6.4), isFalse); + }); + + test('boundaries are inclusive', () { + expect(hba1c.inRange(4.0), isTrue); + expect(hba1c.inRange(5.6), isTrue); + expect(hba1c.inRange(3.9), isFalse); + expect(hba1c.inRange(5.7), isFalse); + }); + + test('no range at all means no opinion, not "fine"', () { + const bare = LabMarker( + key: 'bare', + label: 'Bare', + unit: 'x', + category: LabCategory.blood, + ); + expect(bare.inRange(999), isNull); + expect(bare.rangeFor('m'), isNull); + }); + + test('a sex-specific range wins over a general one', () { + const both = LabMarker( + key: 'both', + label: 'Both', + unit: 'x', + category: LabCategory.blood, + ranges: [ + LabRefRange(low: 0, high: 100), + LabRefRange(low: 50, high: 60, scope: LabRefScope.female), + ], + ); + expect(both.rangeFor('f')!.low, 50); + expect(both.rangeFor('m')!.low, 0, reason: 'falls back to the general'); + expect(both.rangeFor(null)!.low, 0); + }); + }); + + group('lookup and custom keys', () { + const custom = LabMarker( + key: 'custom_lp_a', + label: 'Lp(a)', + unit: 'nmol/L', + category: LabCategory.lipids, + custom: true, + ); + + test('finds built-ins and customs', () { + expect(labMarker('ferritin')?.label, 'Ferritin'); + expect(labMarker('custom_lp_a', custom: const [custom])?.label, 'Lp(a)'); + expect(labMarker('nope'), isNull); + }); + + test('a built-in wins over a same-keyed custom', () { + const shadow = LabMarker( + key: 'ferritin', + label: 'Mine', + unit: 'nmol/L', + category: LabCategory.iron, + custom: true, + ); + expect(labMarker('ferritin', custom: const [shadow])?.unit, 'ng/mL'); + }); + + test('custom keys are prefixed so a future built-in cannot adopt them', () { + expect(customLabMarkerKey('Lp(a)'), 'custom_lp_a'); + expect(customLabMarkerKey(' Free T3 '), 'custom_free_t3'); + expect( + customLabMarkerKey('Ferritin'), + isNot(anyOf(kLabMarkers.map((m) => m.key))), + ); + }); + }); + + test('formatting respects each marker’s precision', () { + expect(kLabMarkersByKey['ferritin']!.formatWithUnit(42.4), '42 ng/mL'); + expect(kLabMarkersByKey['tsh']!.formatWithUnit(1.234), '1.23 mIU/L'); + // Dart rounds half away from zero, so 5.25 goes up. + expect(kLabMarkersByKey['hba1c']!.formatWithUnit(5.25), '5.3 %'); + expect(kLabMarkersByKey['hba1c']!.formatWithUnit(5.24), '5.2 %'); + }); +} diff --git a/test/lab_result_store_test.dart b/test/lab_result_store_test.dart new file mode 100644 index 0000000..2c591dc --- /dev/null +++ b/test/lab_result_store_test.dart @@ -0,0 +1,180 @@ +// lab_result storage. +// +// Two things matter more than the CRUD. A result is keyed on (marker, date +// drawn), so re-entering a value CORRECTS the typo rather than stacking a +// second reading a chart would then average. And each row carries its own +// unit, so a value keeps the unit it was entered under even if the catalogue's +// canonical unit changes later — silently reinterpreting 400 ng/mL as +// 400 nmol/L would be the worst kind of fabrication this app can make. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/lab_catalogue.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_lab_result_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + tearDownAll(() async => LocalDb.close()); + + setUp(() async { + final db = await LocalDb.instance; + await db.delete('lab_result'); + await db.delete('lab_marker_def'); + }); + + test('a result round-trips', () async { + await LocalDb.putLabResult( + marker: 'ferritin', + takenOn: '2026-03-04', + value: 42, + unit: 'ng/mL', + note: 'fasted', + ); + final rows = await LocalDb.labResults(); + expect(rows, hasLength(1)); + expect(rows.single['marker'], 'ferritin'); + expect(rows.single['value'], 42.0); + expect(rows.single['unit'], 'ng/mL'); + expect(rows.single['note'], 'fasted'); + }); + + test('re-entering the same draw corrects it instead of duplicating', () async { + await LocalDb.putLabResult( + marker: 'ferritin', + takenOn: '2026-03-04', + value: 420, + unit: 'ng/mL', + ); + await LocalDb.putLabResult( + marker: 'ferritin', + takenOn: '2026-03-04', + value: 42, + unit: 'ng/mL', + ); + final rows = await LocalDb.labResults(marker: 'ferritin'); + expect(rows, hasLength(1), reason: 'a typo must not become a data point'); + expect(rows.single['value'], 42.0); + }); + + test('two draws of the same marker are two rows', () async { + await LocalDb.putLabResult( + marker: 'ferritin', + takenOn: '2026-03-04', + value: 42, + unit: 'ng/mL', + ); + await LocalDb.putLabResult( + marker: 'ferritin', + takenOn: '2026-09-04', + value: 61, + unit: 'ng/mL', + ); + final rows = await LocalDb.labResults(marker: 'ferritin'); + expect(rows.map((r) => r['taken_on']), ['2026-09-04', '2026-03-04'], + reason: 'newest draw first'); + }); + + test('markers do not collide with each other', () async { + await LocalDb.putLabResult( + marker: 'ferritin', + takenOn: '2026-03-04', + value: 42, + unit: 'ng/mL', + ); + await LocalDb.putLabResult( + marker: 'hba1c', + takenOn: '2026-03-04', + value: 5.2, + unit: '%', + ); + expect(await LocalDb.labResults(), hasLength(2)); + expect(await LocalDb.labResults(marker: 'hba1c'), hasLength(1)); + }); + + test('a row keeps the unit it was entered under', () async { + // Even if the catalogue later changes its canonical unit, the stored + // reading must not be reinterpreted. + await LocalDb.putLabResult( + marker: 'custom_lp_a', + takenOn: '2026-03-04', + value: 90, + unit: 'nmol/L', + ); + expect( + (await LocalDb.labResults(marker: 'custom_lp_a')).single['unit'], + 'nmol/L', + ); + }); + + test('deleting removes only that draw', () async { + for (final d in ['2026-03-04', '2026-09-04']) { + await LocalDb.putLabResult( + marker: 'ferritin', + takenOn: d, + value: 42, + unit: 'ng/mL', + ); + } + await LocalDb.deleteLabResult('ferritin', '2026-03-04'); + final rows = await LocalDb.labResults(marker: 'ferritin'); + expect(rows.map((r) => r['taken_on']), ['2026-09-04']); + }); + + group('custom marker definitions', () { + test('round-trip, and no reference range is invented', () async { + await LocalDb.putLabMarkerDef({ + 'key': customLabMarkerKey('Lp(a)'), + 'label': 'Lp(a)', + 'unit': 'nmol/L', + 'category': LabCategory.lipids.name, + 'decimals': 0, + 'ref_low': null, + 'ref_high': null, + }); + final defs = await LocalDb.labMarkerDefs(); + expect(defs.single['key'], 'custom_lp_a'); + expect(defs.single['unit'], 'nmol/L'); + expect( + defs.single['ref_low'], + isNull, + reason: 'a marker the app knows nothing about gets no verdict', + ); + }); + + test('deleting a definition keeps the readings', () async { + await LocalDb.putLabMarkerDef({ + 'key': 'custom_lp_a', + 'label': 'Lp(a)', + 'unit': 'nmol/L', + 'category': LabCategory.lipids.name, + 'decimals': 0, + }); + await LocalDb.putLabResult( + marker: 'custom_lp_a', + takenOn: '2026-03-04', + value: 90, + unit: 'nmol/L', + ); + + await LocalDb.deleteLabMarkerDef('custom_lp_a'); + + expect(await LocalDb.labMarkerDefs(), isEmpty); + final rows = await LocalDb.labResults(marker: 'custom_lp_a'); + expect(rows, hasLength(1)); + expect( + rows.single['unit'], + 'nmol/L', + reason: 'the row carries its own unit, so it stays readable', + ); + }); + }); +}