Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions openhealth/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ import 'package:openglucose/src/app_controller.dart';
import 'package:openglucose/src/dashboard_chart.dart';
import 'package:openglucose/src/display_preferences.dart';
import 'package:openglucose/src/driver_factory.dart';
import 'package:openglucose/src/metrics_section.dart';
import 'package:openglucose/src/session_presentation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';

/// When built with `--dart-define=OG_DEMO=true`, the app runs the in-memory
/// demo driver (see [buildDefaultDriver]) and auto-connects on launch so it
/// lands directly on the populated dashboard — used for simulator/feature
/// verification. Defaults to false; production builds are unaffected.
const bool kOgDemo = bool.fromEnvironment('OG_DEMO', defaultValue: false);

Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

Expand All @@ -35,9 +42,31 @@ Future<CgmAppController> _bootstrap() async {
driver: buildDefaultDriver(),
);
await controller.initialize();
if (kOgDemo) {
// In demo mode (simulator/feature verification) the demo driver only
// surfaces its sensor after a scan, so auto-scan and auto-connect to land
// directly on the populated dashboard. Strictly gated behind OG_DEMO and
// skipped when a previous session was already restored from preferences.
unawaited(_autoConnectDemoSensor(controller));
}
return controller;
}

/// Scans with the demo driver and connects to the first discovered sensor so
/// OG_DEMO builds open straight onto the populated dashboard.
Future<void> _autoConnectDemoSensor(CgmAppController controller) async {
if (controller.snapshot != null) {
// A persisted session is already (re)connecting; don't interfere.
return;
}
await controller.scan();
final sensors = controller.sensors;
if (sensors.isEmpty) {
return;
}
await controller.connect(sensors.first);
}

class _BootstrapApp extends StatefulWidget {
const _BootstrapApp();

Expand Down Expand Up @@ -124,11 +153,7 @@ class _SpinningLogoState extends State<_SpinningLogo>
Widget build(BuildContext context) {
return RotationTransition(
turns: _controller,
child: Image.asset(
'assets/icon/logo.png',
width: 140,
height: 140,
),
child: Image.asset('assets/icon/logo.png', width: 140, height: 140),
);
}
}
Expand Down Expand Up @@ -555,6 +580,17 @@ class _DashboardView extends StatelessWidget {
),
),
),
// --- TASK-012 metrics pack (explainable wellness patterns) ---
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: MetricsSection(
readings: history,
preferences: preferences,
),
),
),
// --- end TASK-012 metrics pack ---
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
Expand Down
11 changes: 11 additions & 0 deletions openhealth/lib/src/driver_factory_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ import 'package:cgm_aidex/cgm_aidex.dart';
import 'package:cgm_ble_flutter/cgm_ble_flutter.dart';
import 'package:cgm_core/cgm_core.dart';

import 'demo_driver.dart';

/// When built with `--dart-define=OG_DEMO=true`, native/simulator builds use the
/// in-memory [DemoCgmDriver] instead of the real BLE driver, so the app can be
/// exercised in the iOS simulator (which has no Bluetooth). Defaults to false,
/// so production builds are unchanged and keep using the real Aidex driver.
const bool kOgDemo = bool.fromEnvironment('OG_DEMO', defaultValue: false);

CgmDriver buildPlatformDriver() {
if (kOgDemo) {
return DemoCgmDriver();
}
return AidexSensorDriver(const FlutterBluePlusTransport());
}
217 changes: 217 additions & 0 deletions openhealth/lib/src/metrics_section.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import 'package:cgm_core/cgm_core.dart';
import 'package:flutter/material.dart';

import 'display_preferences.dart';

/// Explainable, wellness-framed metrics section for the dashboard.
///
/// Renders Time-in-Range, variability (CV/SD), average, an estimated
/// GMI-style indicator and a spike count for a selectable timeframe, each with
/// a one-line plain-language explanation. These are observations and patterns
/// for self-experimentation, not medical metrics or diagnosis.
class MetricsSection extends StatefulWidget {
const MetricsSection({
super.key,
required this.readings,
required this.preferences,
});

final List<CgmReading> readings;
final DisplayPreferences preferences;

@override
State<MetricsSection> createState() => _MetricsSectionState();
}

class _MetricsSectionState extends State<MetricsSection> {
AnalyticsTimeframe _timeframe = AnalyticsTimeframe.last24h;

static const Color _muted = Color(0xFF5B6E6A);

String _formatGlucose(double mgdl) {
final value = widget.preferences.unit.convertFromMgdl(mgdl);
final digits = widget.preferences.unit == GlucoseUnit.mgdl ? 0 : 1;
return '${value.toStringAsFixed(digits)} ${widget.preferences.unit.label}';
}

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stats = GlucoseAnalytics.summarize(
widget.readings,
timeframe: _timeframe,
);

return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
'Patterns',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
_TimeframeSelector(
value: _timeframe,
onChanged: (value) => setState(() => _timeframe = value),
),
],
),
const SizedBox(height: 4),
Text(
'Observations for self-experimentation, not medical metrics.',
style: theme.textTheme.bodySmall?.copyWith(color: _muted),
),
const SizedBox(height: 14),
if (!stats.hasData)
Text(
'Not enough readings in this window yet.',
style: theme.textTheme.bodyMedium?.copyWith(color: _muted),
)
else
Column(
children: <Widget>[
_MetricRow(
label: 'Time in range',
value: '${stats.timeInRangePercent.round()}%',
explanation:
'Share of readings between '
'${_formatGlucose(stats.bounds.lowMgdl)} and '
'${_formatGlucose(stats.bounds.highMgdl)}.',
),
_MetricRow(
label: 'Below / above',
value:
'${stats.timeBelowRangePercent.round()}% / '
'${stats.timeAboveRangePercent.round()}%',
explanation:
'How often readings sat under the low or over '
'the high mark.',
),
_MetricRow(
label: 'Average',
value: _formatGlucose(stats.averageMgdl!),
explanation: 'Mean of all readings in this window.',
),
_MetricRow(
label: 'Variability (CV)',
value:
'${stats.coefficientOfVariationPercent!.toStringAsFixed(0)}%',
explanation:
'How spread out readings are around the average '
'(SD ${_formatGlucose(stats.standardDeviationMgdl!)}). '
'Lower looks steadier.',
),
_MetricRow(
label: 'Estimated GMI',
value: '~${stats.estimatedGmiPercent!.toStringAsFixed(1)}%',
explanation:
'A rough indicator derived from your average. Not a '
'lab result.',
),
_MetricRow(
label: 'Spikes',
value: '${stats.spikeCount}',
explanation:
'Times readings rose past '
'${_formatGlucose(stats.bounds.highMgdl)}.',
isLast: true,
),
],
),
],
),
),
);
}
}

class _TimeframeSelector extends StatelessWidget {
const _TimeframeSelector({required this.value, required this.onChanged});

final AnalyticsTimeframe value;
final ValueChanged<AnalyticsTimeframe> onChanged;

@override
Widget build(BuildContext context) {
return SegmentedButton<AnalyticsTimeframe>(
style: const ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
segments: <ButtonSegment<AnalyticsTimeframe>>[
for (final timeframe in AnalyticsTimeframe.values)
ButtonSegment<AnalyticsTimeframe>(
value: timeframe,
label: Text(timeframe.label),
),
],
selected: <AnalyticsTimeframe>{value},
showSelectedIcon: false,
onSelectionChanged: (selection) => onChanged(selection.first),
);
}
}

class _MetricRow extends StatelessWidget {
const _MetricRow({
required this.label,
required this.value,
required this.explanation,
this.isLast = false,
});

final String label;
final String value;
final String explanation;
final bool isLast;

static const Color _muted = Color(0xFF5B6E6A);
static const Color _accent = Color(0xFF24443F);

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: <Widget>[
Expanded(
child: Text(
label,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
Text(
value,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w900,
color: _accent,
),
),
],
),
const SizedBox(height: 2),
Text(
explanation,
style: theme.textTheme.bodySmall?.copyWith(color: _muted),
),
],
),
);
}
}
23 changes: 23 additions & 0 deletions openhealth/test/og_demo_flag_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import 'package:cgm_aidex/cgm_aidex.dart';
import 'package:cgm_core/cgm_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:openglucose/src/demo_driver.dart';
import 'package:openglucose/src/driver_factory_io.dart';

void main() {
group('OG_DEMO driver selection', () {
test('selects DemoCgmDriver when OG_DEMO is set, real driver otherwise', () {
final CgmDriver driver = buildPlatformDriver();

// `kOgDemo` is a compile-time constant from --dart-define=OG_DEMO.
// Run with `flutter test --dart-define=OG_DEMO=true` to exercise the
// demo branch; without the define, production behavior is asserted.
if (kOgDemo) {
expect(driver, isA<DemoCgmDriver>());
} else {
expect(driver, isA<AidexSensorDriver>());
expect(driver, isNot(isA<DemoCgmDriver>()));
}
});
});
}
1 change: 1 addition & 0 deletions packages/cgm_core/lib/cgm_core.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ library;

export 'src/cgm_models.dart';
export 'src/cgm_session.dart';
export 'src/glucose_analytics.dart';
Loading