From 189e262ee1e261545aa142431eda8d788344cdbf Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 15:03:27 +0530 Subject: [PATCH 1/3] blood work, and an export a spreadsheet can open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two requests that turned out to share a shape: both are about data the band cannot produce. Labs. Twenty-five common markers with units and typical adult reference intervals, plus anything else you want to name. The intervals are there so a number means something — a bare ferritin is a spreadsheet with extra steps — but they are context, not a verdict. Every laboratory publishes its own interval and the one on your report is the one that applies to you, so a value outside ours reads as "outside the typical range" rather than "high", and nothing here suggests what to do about it. Several markers differ by sex and say so; without a sex in the profile they show no interval at all rather than picking one, because guessing would mark a large share of normal results as abnormal. A marker the app has never heard of gets no range invented for it. Each result carries the unit it was entered under, so a reading is never silently reinterpreted if the catalogue changes later, and a result is keyed on the marker and the date drawn, so fixing a typo corrects the value instead of stacking a second reading a chart would then average. CSV export sits beside the existing .db export, because opening your data in a spreadsheet and restoring it onto a new phone are different jobs and a SQLite file only does the second. Six files — daily metrics, workouts, sleep stages, metric history, journal, labs — all read through the same derived views the coach reads, so an export can never contain something the app itself would not show you and can never reach raw sensor rows or GPS. A metric that was never computed is an empty field, never a zero: nobody can recover the difference once it is in a spreadsheet, and a column of zeroes is something people average. --- lib/data/csv_export.dart | 190 ++++++++++++++ lib/data/db.dart | 121 ++++++++- lib/data/lab_catalogue.dart | 394 +++++++++++++++++++++++++++++ lib/ui/labs/lab_entry_sheet.dart | 374 +++++++++++++++++++++++++++ lib/ui/labs/labs_screen.dart | 315 +++++++++++++++++++++++ lib/ui/profile/profile_screen.dart | 47 ++++ test/csv_export_test.dart | 150 +++++++++++ test/lab_catalogue_test.dart | 180 +++++++++++++ test/lab_result_store_test.dart | 180 +++++++++++++ 9 files changed, 1950 insertions(+), 1 deletion(-) create mode 100644 lib/data/csv_export.dart create mode 100644 lib/data/lab_catalogue.dart create mode 100644 lib/ui/labs/lab_entry_sheet.dart create mode 100644 lib/ui/labs/labs_screen.dart create mode 100644 test/csv_export_test.dart create mode 100644 test/lab_catalogue_test.dart create mode 100644 test/lab_result_store_test.dart diff --git a/lib/data/csv_export.dart b/lib/data/csv_export.dart new file mode 100644 index 00000000..a06880f7 --- /dev/null +++ b/lib/data/csv_export.dart @@ -0,0 +1,190 @@ +// 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', + ), +]; + +/// RFC 4180 field escaping. +/// +/// 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(); + if (s.contains(RegExp('[",\n\r]'))) { + s = '"${s.replaceAll('"', '""')}"'; + return s; + } + 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(); +} + +/// Write the chosen [sets] to CSV files in the temp directory and return their +/// paths, ready for a share sheet. +/// +/// A set whose query fails is SKIPPED rather than failing the whole export — +/// a view can be missing on a database that predates it, and losing five +/// exports because the sixth is unavailable helps nobody. The caller can tell +/// from the returned list which ones landed. +Future> exportCsvFiles( + List sets, { + DateTime? now, +}) async { + final db = await LocalDb.instance; + final dir = await getTemporaryDirectory(); + final stamp = (now ?? DateTime.now()).millisecondsSinceEpoch; + final out = []; + + for (final set in sets) { + try { + final rows = await db.rawQuery(set.sql); + 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)), + ]); + out.add(file.path); + } catch (_) { + // Skip this set; the rest still export. + } + } + return out; +} diff --git a/lib/data/db.dart b/lib/data/db.dart index 2f880d26..6a4b94c4 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -90,7 +90,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 = 27; + 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 (?, ?, …)` @@ -402,6 +402,11 @@ class LocalDb { // for steps), and falls back to band rows otherwise. await _ensureLiveCoverageSource(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); @@ -1295,6 +1300,51 @@ 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 and NOT pruned: a lab result belongs to the date the blood + /// was drawn, not to a band-data day, and it must survive the raw retention + /// window that everything else here is subject to. + 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 INDEX IF NOT EXISTS idx_lab_result_marker ' + 'ON lab_result(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). @@ -1308,6 +1358,7 @@ class LocalDb { updated_at INTEGER NOT NULL ) '''); + await _createLabTables(db); // cycle_log — menstrual cycle markers; `kind` is 'start' (cycle start) etc. await db.execute(''' CREATE TABLE IF NOT EXISTS cycle_log ( @@ -3529,6 +3580,8 @@ class LocalDb { 'metric_series', 'sessions', 'journal', + 'lab_result', + 'lab_marker_def', 'cycle_log', 'notifications', 'baselines', @@ -3835,6 +3888,8 @@ class LocalDb { 'baselines', 'sessions', 'journal', + 'lab_result', + 'lab_marker_def', 'cycle_log', 'notifications', 'sync_cursor', @@ -4507,6 +4562,70 @@ class LocalDb { return db.query('journal', orderBy: 'date DESC'); } + // ── 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 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 00000000..fe1ac1dc --- /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 00000000..0f6194c6 --- /dev/null +++ b/lib/ui/labs/lab_entry_sheet.dart @@ -0,0 +1,374 @@ +// 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/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(); + } + + String get _dateLabel => + '${_takenOn.year}-${_takenOn.month.toString().padLeft(2, '0')}' + '-${_takenOn.day.toString().padLeft(2, '0')}'; + + Future _pickDate() async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: _takenOn, + // Blood cannot be drawn in the future, and a decade covers any history + // worth typing in by hand. + firstDate: DateTime(now.year - 10), + lastDate: now, + ); + if (picked != null) 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. + unit: marker?.unit ?? '', + 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; + } + 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), + ], + 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)'), + ), + ]; + + 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 00000000..670a20c4 --- /dev/null +++ b/lib/ui/labs/labs_screen.dart @@ -0,0 +1,315 @@ +// 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); + } + } + + /// The profile's sex, used only to pick which reference interval applies. + /// Null means several markers simply show no interval — which is the honest + /// outcome, not a reason to guess one. + String? get _sex => (context.read().user?['sex'] as String?) + ?.toLowerCase(); + + 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; + 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( + m == null + ? '${r['value']} ${r['unit']}' + : m.formatWithUnit((r['value'] as num).toDouble()), + 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 c1eac89e..d9555c8e 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -32,6 +32,8 @@ 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'; @@ -243,6 +245,51 @@ 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 { + final messenger = ScaffoldMessenger.of(rowCtx); + final origin = shareOriginFor(rowCtx); + try { + final paths = await exportCsvFiles(kCsvExportSets); + if (!rowCtx.mounted) return; + if (paths.isEmpty) { + messenger.showSnackBar( + const SnackBar(content: Text('Nothing to export yet')), + ); + return; + } + await Share.shareXFiles( + [for (final p in paths) XFile(p)], + text: 'OpenStrap CSV export', + sharePositionOrigin: origin, + ); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Export failed: $e')), + ); + } + }, + 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 00000000..2f1ad19d --- /dev/null +++ b/test/csv_export_test.dart @@ -0,0 +1,150 @@ +// 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 '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:sqflite_common_ffi/sqflite_ffi.dart'; + +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'); + }); + }); +} diff --git a/test/lab_catalogue_test.dart b/test/lab_catalogue_test.dart new file mode 100644 index 00000000..ddbd95c9 --- /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 00000000..2c591dc0 --- /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', + ); + }); + }); +} From f81a1f0c8a64782f9b4c11b7a91a67118ec2fee2 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 15:14:44 +0530 Subject: [PATCH 2/3] a shared CSV should not run code on whoever opens it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Journal notes, tags and lab notes are free text, and a cell starting with =, +, -, @ or a control character is a formula in every mainstream spreadsheet. These files go through a share sheet, so the person who opens them executes it. Text cells are neutralised now; numbers are left alone, or every negative delta in the file would stop being a number. The files were also left in the temp directory forever, one more copy of your readiness, sleep, notes and blood work per export. They go in a directory that is wiped at the start of each run. An export could not tell "you have no data yet" from "the export broke": every set wrote a header-only file even with no rows, and failures were swallowed whole. Empty sets write nothing, failures come back by name, and the message says which happened. Labs: a row's own unit is the authority everywhere, not the marker definition — editing a reading keeps the unit it was measured in, and a reading whose marker was deleted stays readable and editable. Two custom names that slug to the same key no longer silently overwrite each other's definition. The date picker no longer throws on a draw older than its own window. The date label goes through the shared day-label seam. The reference range re-resolves when the profile's sex changes. And the redundant index is gone: the primary key already covers exactly those columns. Schema is 28 here rather than 29, so the ladder has no hole when this lands on its own. --- lib/data/csv_export.dart | 77 ++++++++++++++---- lib/data/db.dart | 22 +++--- lib/ui/labs/lab_entry_sheet.dart | 60 +++++++++++--- lib/ui/labs/labs_screen.dart | 20 +++-- lib/ui/profile/profile_screen.dart | 25 +++++- test/csv_export_test.dart | 121 +++++++++++++++++++++++++++++ test/db_migration_ladder_test.dart | 25 ++++++ 7 files changed, 305 insertions(+), 45 deletions(-) diff --git a/lib/data/csv_export.dart b/lib/data/csv_export.dart index a06880f7..3165501d 100644 --- a/lib/data/csv_export.dart +++ b/lib/data/csv_export.dart @@ -122,7 +122,17 @@ const kCsvExportSets = [ ), ]; -/// RFC 4180 field escaping. +/// 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 @@ -134,9 +144,13 @@ String csvField(Object? v) { // 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]'))) { - s = '"${s.replaceAll('"', '""')}"'; - return s; + return '"${s.replaceAll('"', '""')}"'; } return s; } @@ -153,25 +167,56 @@ String renderCsv(List columns, List> rows) { return b.toString(); } -/// Write the chosen [sets] to CSV files in the temp directory and return their -/// paths, ready for a share sheet. +/// 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; +} + +/// Subdirectory every CSV export writes into, so the previous run can be +/// cleared wholesale. +const _csvDirName = 'openstrap_csv'; + +/// Write the chosen [sets] to CSV files and return what landed. /// -/// A set whose query fails is SKIPPED rather than failing the whole export — -/// a view can be missing on a database that predates it, and losing five -/// exports because the sixth is unavailable helps nobody. The caller can tell -/// from the returned list which ones landed. -Future> exportCsvFiles( +/// 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 dir = await getTemporaryDirectory(); + final root = await getTemporaryDirectory(); + final dir = Directory(p.join(root.path, _csvDirName)); + if (await dir.exists()) await dir.delete(recursive: true); + await dir.create(recursive: true); + final stamp = (now ?? DateTime.now()).millisecondsSinceEpoch; - final out = []; + 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. @@ -181,10 +226,12 @@ Future> exportCsvFiles( 0xBF, ...utf8.encode(renderCsv(set.columns, rows)), ]); - out.add(file.path); + paths.add(file.path); } catch (_) { - // Skip this set; the rest still export. + // 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 out; + return CsvExportResult(paths: paths, failed: failed); } diff --git a/lib/data/db.dart b/lib/data/db.dart index 6a4b94c4..47a9dacc 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -90,7 +90,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 = 29; + static const int schemaVersion = 28; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -402,7 +402,7 @@ class LocalDb { // for steps), and falls back to band rows otherwise. await _ensureLiveCoverageSource(db); } - if (oldV < 29) { + if (oldV < 28) { // Hand-entered blood work. Purely new tables; nothing existing is // read or rewritten. await _createLabTables(db); @@ -1312,9 +1312,17 @@ class LocalDb { /// 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 and NOT pruned: a lab result belongs to the date the blood - /// was drawn, not to a band-data day, and it must survive the raw retention - /// window that everything else here is subject to. + /// 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 ( @@ -1327,10 +1335,6 @@ class LocalDb { PRIMARY KEY (marker, taken_on) ) '''); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_lab_result_marker ' - 'ON lab_result(marker, taken_on)', - ); await db.execute(''' CREATE TABLE IF NOT EXISTS lab_marker_def ( key TEXT PRIMARY KEY, diff --git a/lib/ui/labs/lab_entry_sheet.dart b/lib/ui/labs/lab_entry_sheet.dart index 0f6194c6..7def75c8 100644 --- a/lib/ui/labs/lab_entry_sheet.dart +++ b/lib/ui/labs/lab_entry_sheet.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; +import '../../data/day_label.dart'; import '../../data/db.dart'; import '../../data/lab_catalogue.dart'; import '../design/design.dart'; @@ -73,18 +74,24 @@ class _LabEntrySheetState extends State<_LabEntrySheet> { super.dispose(); } - String get _dateLabel => - '${_takenOn.year}-${_takenOn.month.toString().padLeft(2, '0')}' - '-${_takenOn.day.toString().padLeft(2, '0')}'; + // 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: _takenOn, - // Blood cannot be drawn in the future, and a decade covers any history - // worth typing in by hand. - firstDate: DateTime(now.year - 10), + initialDate: initial, + firstDate: first, lastDate: now, ); if (picked != null) setState(() => _takenOn = picked); @@ -107,8 +114,11 @@ class _LabEntrySheetState extends State<_LabEntrySheet> { 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. - unit: marker?.unit ?? '', + // 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); @@ -133,6 +143,17 @@ class _LabEntrySheetState extends State<_LabEntrySheet> { 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, @@ -203,7 +224,12 @@ class _LabEntrySheetState extends State<_LabEntrySheet> { ..._customFields() else ...[ if (!_isEdit) ..._markerPicker(), - if (marker != null) ..._valueFields(marker), + 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), @@ -338,6 +364,20 @@ class _LabEntrySheetState extends State<_LabEntrySheet> { ), ]; + /// 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 ' diff --git a/lib/ui/labs/labs_screen.dart b/lib/ui/labs/labs_screen.dart index 670a20c4..e0c9ec28 100644 --- a/lib/ui/labs/labs_screen.dart +++ b/lib/ui/labs/labs_screen.dart @@ -73,11 +73,6 @@ class _LabsScreenState extends State { } } - /// The profile's sex, used only to pick which reference interval applies. - /// Null means several markers simply show no interval — which is the honest - /// outcome, not a reason to guess one. - String? get _sex => (context.read().user?['sex'] as String?) - ?.toLowerCase(); Future _add() async { if (await showLabEntrySheet(context, custom: _custom) && mounted) { @@ -106,6 +101,13 @@ class _LabsScreenState extends State { @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: [ @@ -175,7 +177,7 @@ class _LabsScreenState extends State { marker: labMarker(entry.key, custom: _custom), fallbackKey: entry.key, results: entry.value, - sex: _sex, + sex: sex, onTapResult: _edit, ), ), @@ -287,9 +289,13 @@ class _MarkerCard extends StatelessWidget { ), ), 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.formatWithUnit((r['value'] as num).toDouble()), + : '${m.format((r['value'] as num).toDouble())} ' + '${r['unit']}', style: AppText.body, ), ], diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index d9555c8e..5c00664a 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -268,16 +268,33 @@ class ProfileScreen extends StatelessWidget { final messenger = ScaffoldMessenger.of(rowCtx); final origin = shareOriginFor(rowCtx); try { - final paths = await exportCsvFiles(kCsvExportSets); + final result = await exportCsvFiles(kCsvExportSets); if (!rowCtx.mounted) return; - if (paths.isEmpty) { + if (result.isEmpty) { + // "Nothing to export" and "the export broke" are + // different answers and must not share a message. messenger.showSnackBar( - const SnackBar(content: Text('Nothing to export yet')), + 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 paths) XFile(p)], + [for (final p in result.paths) XFile(p)], text: 'OpenStrap CSV export', sharePositionOrigin: origin, ); diff --git a/test/csv_export_test.dart b/test/csv_export_test.dart index 2f1ad19d..20007e59 100644 --- a/test/csv_export_test.dart +++ b/test/csv_export_test.dart @@ -5,12 +5,32 @@ // 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', () { @@ -147,4 +167,105 @@ void main() { 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'); + + final result = await exportCsvFiles([daily, labs, broken]); + + 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('a run clears the previous run rather than piling up copies', () async { + // These are plaintext health files; one export's worth may exist at a + // time, not one per tap forever. + final daily = kCsvExportSets.firstWhere((s) => s.name == 'daily'); + final first = await exportCsvFiles([daily], now: DateTime(2026, 7, 1)); + expect(first.paths, hasLength(1)); + + final second = await exportCsvFiles([daily], now: DateTime(2026, 7, 2)); + expect(second.paths, hasLength(1)); + expect(File(first.paths.single).existsSync(), isFalse); + expect(File(second.paths.single).existsSync(), isTrue); + + final dir = Directory(p.dirname(second.paths.single)); + expect(dir.listSync().whereType(), hasLength(1)); + }); + + 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]); + expect(empty.isEmpty, isTrue); + expect(empty.hasFailures, isFalse); + + final failed = await exportCsvFiles([broken]); + 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 7e53f2ab..93d35dd8 100644 --- a/test/db_migration_ladder_test.dart +++ b/test/db_migration_ladder_test.dart @@ -293,4 +293,29 @@ void main() { ); }, ); + + 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'); + }, + ); } From 6154a868e47e55da04f62d13933deafe0557b6a2 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 15:23:13 +0530 Subject: [PATCH 3/3] wiping the export directory raced the share sheet reading from it Clearing every previous run at the start of a new one was my own fix to the files piling up, and it introduced a worse problem: the export returns before the share sheet has finished reading, so a second export could delete the files the first share was still handing over. Each run gets its own directory now and the previous one survives, so a second export cannot destroy the first's files while still bounding how many copies of plaintext health data sit on disk. The export row also refuses to re-enter while a share is open. Plus two missing mounted guards after awaits. --- lib/data/csv_export.dart | 49 +++++++++++++++++++++++++++--- lib/ui/labs/lab_entry_sheet.dart | 2 +- lib/ui/profile/profile_screen.dart | 16 ++++++++++ test/csv_export_test.dart | 44 +++++++++++++++++++-------- 4 files changed, 93 insertions(+), 18 deletions(-) diff --git a/lib/data/csv_export.dart b/lib/data/csv_export.dart index 3165501d..519e1aed 100644 --- a/lib/data/csv_export.dart +++ b/lib/data/csv_export.dart @@ -185,10 +185,20 @@ class CsvExportResult { bool get hasFailures => failed.isNotEmpty; } -/// Subdirectory every CSV export writes into, so the previous run can be -/// cleared wholesale. +/// 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, @@ -202,11 +212,13 @@ Future exportCsvFiles( }) async { final db = await LocalDb.instance; final root = await getTemporaryDirectory(); - final dir = Directory(p.join(root.path, _csvDirName)); - if (await dir.exists()) await dir.delete(recursive: true); - await dir.create(recursive: true); + 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 = []; @@ -235,3 +247,30 @@ Future exportCsvFiles( } 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/ui/labs/lab_entry_sheet.dart b/lib/ui/labs/lab_entry_sheet.dart index 7def75c8..194a7d98 100644 --- a/lib/ui/labs/lab_entry_sheet.dart +++ b/lib/ui/labs/lab_entry_sheet.dart @@ -94,7 +94,7 @@ class _LabEntrySheetState extends State<_LabEntrySheet> { firstDate: first, lastDate: now, ); - if (picked != null) setState(() => _takenOn = picked); + if (picked != null && mounted) setState(() => _takenOn = picked); } Future _save() async { diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index 5c00664a..2bbb10d1 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -39,6 +39,14 @@ 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}); @@ -265,6 +273,11 @@ class ProfileScreen extends StatelessWidget { 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 { @@ -299,9 +312,12 @@ class ProfileScreen extends StatelessWidget { sharePositionOrigin: origin, ); } catch (e) { + if (!rowCtx.mounted) return; messenger.showSnackBar( SnackBar(content: Text('Export failed: $e')), ); + } finally { + _csvExportInFlight = false; } }, divider: true, diff --git a/test/csv_export_test.dart b/test/csv_export_test.dart index 20007e59..4ec8f5de 100644 --- a/test/csv_export_test.dart +++ b/test/csv_export_test.dart @@ -222,7 +222,13 @@ void main() { final daily = kCsvExportSets.firstWhere((s) => s.name == 'daily'); final labs = kCsvExportSets.firstWhere((s) => s.name == 'labs'); - final result = await exportCsvFiles([daily, labs, broken]); + // 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']); @@ -234,20 +240,34 @@ void main() { reason: 'without the BOM, Excel on Windows mangles every note'); }); - test('a run clears the previous run rather than piling up copies', () async { - // These are plaintext health files; one export's worth may exist at a - // time, not one per tap forever. + 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)); - expect(first.paths, hasLength(1)); - final second = await exportCsvFiles([daily], now: DateTime(2026, 7, 2)); - expect(second.paths, hasLength(1)); - expect(File(first.paths.single).existsSync(), isFalse); + + 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 dir = Directory(p.dirname(second.paths.single)); - expect(dir.listSync().whereType(), hasLength(1)); + 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 { @@ -259,11 +279,11 @@ void main() { ); final labs = kCsvExportSets.firstWhere((s) => s.name == 'labs'); - final empty = await exportCsvFiles([labs]); + final empty = await exportCsvFiles([labs], now: DateTime(2026, 9, 1)); expect(empty.isEmpty, isTrue); expect(empty.hasFailures, isFalse); - final failed = await exportCsvFiles([broken]); + final failed = await exportCsvFiles([broken], now: DateTime(2026, 9, 2)); expect(failed.isEmpty, isTrue); expect(failed.hasFailures, isTrue); });