diff --git a/openhealth/lib/main.dart b/openhealth/lib/main.dart index 5e83274..9ee0aea 100644 --- a/openhealth/lib/main.dart +++ b/openhealth/lib/main.dart @@ -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/mock_scenarios.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 main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -35,9 +42,31 @@ Future _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 _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(); @@ -936,10 +965,15 @@ Future _showSettings( ), _buildDeveloperSettingsPane( context: context, + controller: controller, snapshot: snapshot, diagnostics: diagnostics, calibrations: calibrations, logs: logs, + onScenarioChanged: (scenario) { + controller.applyMockScenario(scenario); + setState(() {}); + }, ), ], ), @@ -1113,10 +1147,12 @@ Widget _buildSensorSettingsPane( Widget _buildDeveloperSettingsPane({ required BuildContext context, + required CgmAppController controller, required CgmSessionSnapshot snapshot, required List diagnostics, required List calibrations, required List logs, + required ValueChanged onScenarioChanged, }) { final metadataEntries = >[ MapEntry('deviceId', snapshot.sensor.deviceId), @@ -1138,6 +1174,42 @@ Widget _buildDeveloperSettingsPane({ ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), ), const SizedBox(height: 16), + if (controller.isMockDriver) ...[ + Text( + 'Mock scenario', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w900), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + key: const ValueKey('mockScenarioPicker'), + initialValue: controller.mockScenario ?? MockScenario.activeNormal, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Simulated sensor state'), + items: MockScenario.values + .map( + (scenario) => DropdownMenuItem( + value: scenario, + child: Text(scenario.label), + ), + ) + .toList(growable: false), + onChanged: (scenario) { + if (scenario != null) { + onScenarioChanged(scenario); + } + }, + ), + const SizedBox(height: 6), + Text( + (controller.mockScenario ?? MockScenario.activeNormal).description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: const Color(0xFF5B6E6A), + ), + ), + const Divider(height: 28), + ], Text( 'Metadata', style: Theme.of( diff --git a/openhealth/lib/src/app_controller.dart b/openhealth/lib/src/app_controller.dart index 78bcbc7..b22a418 100644 --- a/openhealth/lib/src/app_controller.dart +++ b/openhealth/lib/src/app_controller.dart @@ -6,9 +6,11 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'android_live_update_bridge.dart'; +import 'demo_driver.dart'; import 'display_preferences.dart'; import 'ios_live_activity_bridge.dart'; import 'live_activity_payload.dart'; +import 'mock_scenarios.dart'; class CgmAppController extends ChangeNotifier { CgmAppController({ @@ -425,6 +427,38 @@ class CgmAppController extends ChangeNotifier { notifyListeners(); } + /// Whether the active driver is the OG_DEMO mock driver, i.e. the Developer + /// scenario switcher should be shown. + bool get isMockDriver => _driver is DemoCgmDriver; + + /// The mock scenario currently driving the demo session, or null when not in + /// demo mode. + MockScenario? get mockScenario { + final driver = _driver; + return driver is DemoCgmDriver ? driver.scenario : null; + } + + /// Switches the live mock scenario without a rebuild. No-op outside OG_DEMO. + /// The demo session emits a fresh snapshot through the existing stream, so + /// the dashboard updates automatically. + void applyMockScenario(MockScenario scenario) { + final driver = _driver; + if (driver is! DemoCgmDriver) { + return; + } + final session = driver.applyScenario(scenario); + if (session == null) { + // Not connected yet; the new scenario becomes the default for the next + // connect. Trigger a (re)connect to surface it immediately. + unawaited(connect(_selectedSensor ?? driver.scenarioSensor)); + return; + } + _snapshot = session.currentSnapshot; + _lastError = _snapshot?.lastError; + unawaited(_pushLiveActivity()); + notifyListeners(); + } + void clearPersistedHistory() { final sensor = _selectedSensor; if (sensor == null) { diff --git a/openhealth/lib/src/demo_driver.dart b/openhealth/lib/src/demo_driver.dart index 972cb5a..0be6982 100644 --- a/openhealth/lib/src/demo_driver.dart +++ b/openhealth/lib/src/demo_driver.dart @@ -3,21 +3,36 @@ import 'dart:math' as math; import 'package:cgm_core/cgm_core.dart'; +import 'mock_scenarios.dart'; + +/// In-memory CGM driver used by the OG_DEMO harness so the app can be exercised +/// in the iOS simulator (no Bluetooth) and in widget tests. +/// +/// The driver is **scenario-driven**: it builds snapshots from +/// [MockScenarioCatalog] for a selectable [MockScenario]. The default scenario +/// is [MockScenario.activeNormal], which preserves the original demo behaviour. +/// The live scenario can be swapped at runtime via +/// [DemoCgmSession.applyScenario] (wired to the Developer-tab picker). class DemoCgmDriver implements CgmDriver { - DemoCgmDriver({DateTime Function()? clock}) : _clock = clock ?? DateTime.now; + DemoCgmDriver({ + MockScenario initialScenario = MockScenario.activeNormal, + DateTime Function()? clock, + }) : _scenario = initialScenario, + _clock = clock ?? DateTime.now; final DateTime Function() _clock; + MockScenario _scenario; + + /// The active session, if connected. Exposed so the app controller can swap + /// the live scenario without reconnecting. + DemoCgmSession? _session; + + /// The currently selected scenario (initial define or last runtime switch). + MockScenario get scenario => _session?.scenario ?? _scenario; - static const _capabilities = CgmCapabilities( - supportsDirectBle: true, - supportsVendorPairing: true, - supportsAdvertisementGlucose: true, - supportsHistory: true, - supportsRawHistory: true, - supportsCalibration: true, - supportsDiagnostics: true, - supportsCommunicationInterval: true, - ); + /// The sensor this driver advertises, for callers that need to (re)connect + /// without first running a scan. + DiscoveredSensor get scenarioSensor => MockScenarioCatalog.sensor; @override String get driverId => 'demo-aidex'; @@ -28,58 +43,68 @@ class DemoCgmDriver implements CgmDriver { bool allowDuplicates = true, }) async* { await Future.delayed(const Duration(milliseconds: 250)); - yield DiscoveredSensor( - driverId: driverId, - deviceId: 'demo-aidex-07A12', - displayName: 'AiDEX Demo 07A12', - storageKey: 'demo:07A12', - rssi: -46, - capabilities: _capabilities, - advertisement: const CgmAdvertisement( - payloadHex: '5900DEMO0712', - counter: 12, - phaseHex: '21', - glucoseTriplet: [122, 124, 127], - qualifiers: [1, 0, 0], - displayValueMgdl: 124, - ), - notes: 'Demo transport for web, screenshots, and widget tests.', - metadata: const {'serial': '07A12', 'mode': 'demo'}, - ); + yield MockScenarioCatalog.sensor; } @override Future connect(DiscoveredSensor sensor) async { - return DemoCgmSession(sensor: sensor, clock: _clock); + final session = DemoCgmSession( + sensor: sensor, + scenario: _scenario, + clock: _clock, + ); + _session = session; + return session; + } + + /// Swaps the live scenario on the active session (if any) and remembers it + /// as the default for future connections. Returns the connected session, or + /// null if not connected. + DemoCgmSession? applyScenario(MockScenario scenario) { + _scenario = scenario; + final session = _session; + if (session != null && !session.isClosed) { + session.applyScenario(scenario); + return session; + } + return null; } } class DemoCgmSession implements CgmSession { - DemoCgmSession({required this.sensor, required DateTime Function() clock}) - : _clock = clock { - final snapshot = _buildSnapshot(); - _snapshot = snapshot; - _history = snapshot.history; - _calibrations = snapshot.calibrations; - _diagnostics = snapshot.diagnostics; - _log(CgmLogLevel.info, 'Demo session initialized'); + DemoCgmSession({ + required this.sensor, + required MockScenario scenario, + required DateTime Function() clock, + }) : _scenario = scenario, + _clock = clock, + _catalog = MockScenarioCatalog(clock: clock) { + _applyScenarioInternal(scenario, announce: false); + _log(CgmLogLevel.info, 'Demo session initialized (${scenario.label})'); } @override final DiscoveredSensor sensor; final DateTime Function() _clock; + final MockScenarioCatalog _catalog; final StreamController _snapshotController = StreamController.broadcast(); final StreamController _logController = StreamController.broadcast(); + MockScenario _scenario; late CgmSessionSnapshot _snapshot; late List _history; late List _calibrations; late List _diagnostics; bool _closed = false; + /// The scenario currently driving this session. + MockScenario get scenario => _scenario; + + bool get isClosed => _closed; + @override CgmSessionSnapshot get currentSnapshot => _snapshot; @@ -92,14 +117,43 @@ class DemoCgmSession implements CgmSession { @override CgmUnsafeAdmin? get unsafeAdmin => null; + /// Rebuilds the session from [scenario] and emits the new snapshot so the UI + /// switches live without a reconnect. Gated behind OG_DEMO by the caller. + void applyScenario(MockScenario scenario) { + _ensureOpen(); + _applyScenarioInternal(scenario, announce: true); + _log(CgmLogLevel.info, 'Demo scenario switched to ${scenario.label}'); + } + + void _applyScenarioInternal(MockScenario scenario, {required bool announce}) { + _scenario = scenario; + final snapshot = _catalog.buildSnapshot(scenario); + _history = snapshot.history; + _calibrations = snapshot.calibrations; + _diagnostics = snapshot.diagnostics; + if (announce) { + _emitSnapshot(snapshot); + } else { + _snapshot = snapshot; + } + } + @override Future refresh() async { _ensureOpen(); + // Warmup / error / expired sessions have no live readings to refresh. + if (_history.isEmpty) { + _emitSnapshot(_snapshot); + return; + } final latestTime = _clock(); final sessionStart = _snapshot.sessionInfo.sessionStart ?? latestTime; final sensorMinute = latestTime.difference(sessionStart).inMinutes; + // Continue around the last known value so the live tick matches the + // scenario's glucose band (high stays high, low stays low, etc.). + final base = _history.last.valueMgdl; final latest = CgmReading( - valueMgdl: 118 + (math.sin(sensorMinute / 11) * 10), + valueMgdl: base + (math.sin(sensorMinute / 11) * 6), source: CgmRecordSource.broadcast, sensorMinute: sensorMinute, recordedAt: latestTime, @@ -201,7 +255,6 @@ class DemoCgmSession implements CgmSession { @override Future> refreshDiagnostics() async { _ensureOpen(); - _diagnostics = _buildDiagnostics(); _emitSnapshot(_snapshot.copyWith(diagnostics: _diagnostics)); _log(CgmLogLevel.debug, 'Demo diagnostics refreshed'); return _diagnostics; @@ -217,109 +270,6 @@ class DemoCgmSession implements CgmSession { await _logController.close(); } - CgmSessionSnapshot _buildSnapshot() { - final now = _clock(); - final sessionStart = now.subtract(const Duration(hours: 16)); - final history = List.generate(48, (index) { - final recordedAt = now.subtract(Duration(minutes: (47 - index) * 5)); - final sensorMinute = recordedAt.difference(sessionStart).inMinutes; - final value = 118 + (math.sin(index / 4) * 16) + ((index % 5) - 2); - return CgmReading( - valueMgdl: value, - source: index == 47 - ? CgmRecordSource.broadcast - : CgmRecordSource.vendor, - sensorMinute: sensorMinute, - recordedAt: recordedAt, - rawValue: value.round(), - qualifier: 1, - ); - }); - final diagnostics = _buildDiagnostics(); - final calibrations = [ - CgmCalibrationEntry( - index: 1, - glucoseMgdl: 118, - sensorMinute: history[20].sensorMinute, - recordedAt: history[20].recordedAt, - payloadHex: '7600150c', - ), - ]; - return CgmSessionSnapshot( - stage: CgmSyncStage.ready, - statusText: 'Demo session ready', - sensor: sensor, - capabilities: sensor.capabilities, - latestReading: history.last, - lastAdvertisement: sensor.advertisement, - history: history, - rawHistory: history - .map((reading) => reading.copyWith(source: CgmRecordSource.raw)) - .toList(growable: false), - calibrations: calibrations, - diagnostics: diagnostics, - sessionInfo: CgmSessionInfo( - manufacturer: 'MicroTech Medical', - model: 'AiDEX X', - serial: sensor.metadata['serial'] ?? '07A12', - firmware: '1.9.7-demo', - sessionStart: sessionStart, - sessionStartPayloadHex: 'E907040D01080C0000', - elapsedMinutes: history.last.sensorMinute, - warmupMinutes: 60, - ), - health: const CgmHealthSnapshot( - statusText: 'Sensor active', - warningFlagsHex: '0x00', - sensorCheckHex: '0100', - ), - historySync: CgmHistorySyncState( - storedCount: history.length, - totalAvailable: history.length, - latestStoredOffset: history.last.sensorMinute, - startIndex: history.first.sensorMinute, - targetIndex: history.last.sensorMinute, - lastSyncAt: now, - ), - metadata: { - 'deviceId': sensor.deviceId, - 'serial': sensor.metadata['serial'] ?? '07A12', - 'mode': 'demo', - 'driverId': sensor.driverId, - 'historyCount': history.length.toString(), - }, - ); - } - - List _buildDiagnostics() { - return [ - CgmDiagnosticItem( - key: 'pair', - title: 'Vendor Pair State', - summary: 'Demo transport always reports the vendor session as ready.', - rawHex: '1122334455667788', - ), - const CgmDiagnosticItem( - key: 'device', - title: 'Device Information', - fields: { - 'manufacturer': 'MicroTech Medical', - 'model': 'AiDEX X', - 'firmware': '1.9.7-demo', - }, - ), - const CgmDiagnosticItem( - key: 'status', - title: 'Standard CGM State', - fields: { - 'featureHex': '8B3F', - 'statusHex': '0000010000000000', - 'sessionRunTimeHex': '00480000', - }, - ), - ]; - } - void _emitSnapshot(CgmSessionSnapshot snapshot) { _snapshot = snapshot; if (!_snapshotController.isClosed) { diff --git a/openhealth/lib/src/driver_factory_io.dart b/openhealth/lib/src/driver_factory_io.dart index 082a6c9..e4e1cea 100644 --- a/openhealth/lib/src/driver_factory_io.dart +++ b/openhealth/lib/src/driver_factory_io.dart @@ -2,6 +2,24 @@ 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'; +import 'mock_scenarios.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); + +/// Initial mock scenario for OG_DEMO builds, e.g. +/// `--dart-define=OG_SCENARIO=activeHigh`. Unknown/empty values fall back to +/// [MockScenario.activeNormal]. Ignored unless OG_DEMO is set. The scenario can +/// also be switched at runtime from the Developer settings tab. +const String kOgScenario = String.fromEnvironment('OG_SCENARIO'); + CgmDriver buildPlatformDriver() { + if (kOgDemo) { + return DemoCgmDriver(initialScenario: MockScenario.fromId(kOgScenario)); + } return AidexSensorDriver(const FlutterBluePlusTransport()); } diff --git a/openhealth/lib/src/driver_factory_stub.dart b/openhealth/lib/src/driver_factory_stub.dart index ab75d91..8a44c63 100644 --- a/openhealth/lib/src/driver_factory_stub.dart +++ b/openhealth/lib/src/driver_factory_stub.dart @@ -1,5 +1,12 @@ import 'package:cgm_core/cgm_core.dart'; import 'demo_driver.dart'; +import 'mock_scenarios.dart'; -CgmDriver buildPlatformDriver() => DemoCgmDriver(); +/// Initial mock scenario for web/demo builds, e.g. +/// `--dart-define=OG_SCENARIO=activeHigh`. Unknown/empty values fall back to +/// [MockScenario.activeNormal]. +const String kOgScenario = String.fromEnvironment('OG_SCENARIO'); + +CgmDriver buildPlatformDriver() => + DemoCgmDriver(initialScenario: MockScenario.fromId(kOgScenario)); diff --git a/openhealth/lib/src/mock_scenarios.dart b/openhealth/lib/src/mock_scenarios.dart new file mode 100644 index 0000000..14dfa1f --- /dev/null +++ b/openhealth/lib/src/mock_scenarios.dart @@ -0,0 +1,588 @@ +import 'dart:math' as math; + +import 'package:cgm_core/cgm_core.dart'; + +/// Selectable mock sensor scenarios for the OG_DEMO harness. +/// +/// Each scenario maps to a fully-formed [CgmSessionSnapshot] (via +/// [MockScenarioCatalog.buildSnapshot]) so the dashboard renders that exact +/// lifecycle / alert state in the iOS simulator without any real BLE. The +/// mapping is pure and clock-injectable so it can be unit tested. +enum MockScenario { + /// Sensor inside its ~1h warmup window: no readings yet, countdown showing. + warmup, + + /// Healthy in-range glucose, gently oscillating. This is the default and + /// preserves the original demo behaviour. + activeNormal, + + /// Sustained high glucose that should trip a high alert. + activeHigh, + + /// Sustained low glucose that should trip a low alert. + activeLow, + + /// Glucose climbing quickly (rapid-rise trend arrow). + rapidRise, + + /// Glucose dropping quickly (rapid-fall trend arrow). + rapidFall, + + /// Near the end of the 15-day sensor life (hours left). + expiringSoon, + + /// Sensor expired / session stopped — offboarding state. + expired, + + /// Connected but the sensor signal is currently lost (stale readings). + signalLoss, + + /// Disconnected with previously-known data (reconnecting state). + disconnected, + + /// Current sensor plus history carried over from a previous sensor. + multiSensorHistory, + + /// Hard error state with no usable data. + error; + + /// Stable identifier used for `--dart-define=OG_SCENARIO=` and as the + /// persisted runtime selection. + String get id => name; + + /// Human-friendly label for the Developer-tab picker. + String get label => switch (this) { + MockScenario.warmup => 'Warmup', + MockScenario.activeNormal => 'Active — normal', + MockScenario.activeHigh => 'Active — high (alert)', + MockScenario.activeLow => 'Active — low (alert)', + MockScenario.rapidRise => 'Rapid rise', + MockScenario.rapidFall => 'Rapid fall', + MockScenario.expiringSoon => 'Expiring soon', + MockScenario.expired => 'Expired', + MockScenario.signalLoss => 'Signal loss', + MockScenario.disconnected => 'Disconnected', + MockScenario.multiSensorHistory => 'Multi-sensor history', + MockScenario.error => 'Error', + }; + + /// One-line description shown under the picker. + String get description => switch (this) { + MockScenario.warmup => 'Inside the ~1h warmup window — no readings yet.', + MockScenario.activeNormal => 'Healthy in-range glucose, gently waving.', + MockScenario.activeHigh => 'Sustained high glucose — trips a high alert.', + MockScenario.activeLow => 'Sustained low glucose — trips a low alert.', + MockScenario.rapidRise => 'Glucose climbing fast (rising trend).', + MockScenario.rapidFall => 'Glucose dropping fast (falling trend).', + MockScenario.expiringSoon => 'A few hours left on the 15-day sensor.', + MockScenario.expired => 'Sensor expired — offboarding state.', + MockScenario.signalLoss => 'Connected but signal lost — readings stale.', + MockScenario.disconnected => 'Disconnected with last-known data.', + MockScenario.multiSensorHistory => + 'Current sensor + carried-over previous-sensor history.', + MockScenario.error => 'Hard error with no usable data.', + }; + + /// Resolves a scenario id (e.g. from a dart-define) to a [MockScenario], + /// falling back to [activeNormal] for empty/unknown values. + static MockScenario fromId(String? id) { + if (id == null || id.isEmpty) { + return MockScenario.activeNormal; + } + for (final scenario in MockScenario.values) { + if (scenario.id == id) { + return scenario; + } + } + return MockScenario.activeNormal; + } +} + +/// Glucose alert thresholds (mg/dL) the active scenarios are designed to cross. +/// These mirror typical CGM defaults; the high/low scenarios sit clearly +/// outside this band so any alert layer built on the snapshot trips. +const double kMockHighThresholdMgdl = 180; +const double kMockLowThresholdMgdl = 70; + +/// Builds [CgmSessionSnapshot]s for each [MockScenario]. +/// +/// Pure and clock-injectable: pass a fixed `clock` in tests to get +/// deterministic snapshots. +class MockScenarioCatalog { + MockScenarioCatalog({DateTime Function()? clock}) + : _clock = clock ?? DateTime.now; + + final DateTime Function() _clock; + + static const _warmupMinutes = 60; + static const _sensorLife = Duration(days: 15); + + /// The simulated sensor advertised by the demo driver. + static const DiscoveredSensor sensor = DiscoveredSensor( + driverId: 'demo-aidex', + deviceId: 'demo-aidex-07A12', + displayName: 'AiDEX Demo 07A12', + storageKey: 'demo:07A12', + rssi: -46, + capabilities: CgmCapabilities( + supportsDirectBle: true, + supportsVendorPairing: true, + supportsAdvertisementGlucose: true, + supportsHistory: true, + supportsRawHistory: true, + supportsCalibration: true, + supportsDiagnostics: true, + supportsCommunicationInterval: true, + ), + advertisement: CgmAdvertisement( + payloadHex: '5900DEMO0712', + counter: 12, + phaseHex: '21', + glucoseTriplet: [122, 124, 127], + qualifiers: [1, 0, 0], + displayValueMgdl: 124, + ), + notes: 'Demo transport for simulator, screenshots, and widget tests.', + metadata: {'serial': '07A12', 'mode': 'demo'}, + ); + + /// Builds the full snapshot for [scenario] as of "now". + CgmSessionSnapshot buildSnapshot(MockScenario scenario) { + return switch (scenario) { + MockScenario.warmup => _warmup(), + MockScenario.activeNormal => _active(scenario, _normalShape), + MockScenario.activeHigh => _active(scenario, _highShape), + MockScenario.activeLow => _active(scenario, _lowShape), + // Fewer samples so the per-5-minute slope is steep enough that the + // dashboard's trend arrow reads as a genuine rapid rise/fall. + MockScenario.rapidRise => _active( + scenario, + _rapidRiseShape, + sampleCount: 18, + ), + MockScenario.rapidFall => _active( + scenario, + _rapidFallShape, + sampleCount: 18, + ), + MockScenario.expiringSoon => _expiringSoon(), + MockScenario.expired => _expired(), + MockScenario.signalLoss => _signalLoss(), + MockScenario.disconnected => _disconnected(), + MockScenario.multiSensorHistory => _multiSensorHistory(), + MockScenario.error => _error(), + }; + } + + // --- Glucose shape functions: index -> mg/dL ------------------------------ + + double _normalShape(int index, int count) => + 118 + (math.sin(index / 4) * 16) + ((index % 5) - 2); + + double _highShape(int index, int count) => + 232 + (math.sin(index / 5) * 14) + ((index % 4) - 1.5); + + double _lowShape(int index, int count) => + 58 + (math.sin(index / 6) * 6) + ((index % 3) - 1); + + double _rapidRiseShape(int index, int count) => + // Climb steadily from ~80 to ~230 across the window (monotonic so the + // last-step delta clearly reads as rising). + 80 + (index / (count - 1)) * 150 + (math.sin(index / 5) * 2); + + double _rapidFallShape(int index, int count) => + // Drop steadily from ~240 to ~70 across the window. + 240 - (index / (count - 1)) * 170 + (math.sin(index / 5) * 2); + + // --- Scenario builders ---------------------------------------------------- + + /// Active session: a populated, in-progress 15-day sensor whose readings + /// follow [shape]. Used for the normal/high/low/rapid scenarios. + CgmSessionSnapshot _active( + MockScenario scenario, + double Function(int index, int count) shape, { + int sampleCount = 48, + Duration sessionAge = const Duration(hours: 16), + }) { + final now = _clock(); + final sessionStart = now.subtract(sessionAge); + final history = _buildHistory( + now: now, + sessionStart: sessionStart, + count: sampleCount, + shape: shape, + ); + return _readySnapshot( + now: now, + sessionStart: sessionStart, + history: history, + statusText: 'Demo session ready (${scenario.label})', + scenario: scenario, + ); + } + + CgmSessionSnapshot _warmup() { + final now = _clock(); + // Started 20 minutes ago, still ~40 minutes of warmup left, no readings. + final sessionStart = now.subtract(const Duration(minutes: 20)); + return _baseSnapshot( + now: now, + sessionStart: sessionStart, + history: const [], + stage: CgmSyncStage.ready, + statusText: 'Warming up', + health: const CgmHealthSnapshot( + statusText: 'Sensor warming up', + warningFlagsHex: '0x00', + sensorCheckHex: '0100', + ), + scenario: MockScenario.warmup, + latestReading: null, + ); + } + + CgmSessionSnapshot _expiringSoon() { + final now = _clock(); + // ~3 hours of life left out of 15 days. + final sessionStart = now.subtract( + _sensorLife - const Duration(hours: 3), + ); + final history = _buildHistory( + now: now, + sessionStart: sessionStart, + count: 48, + shape: _normalShape, + ); + return _readySnapshot( + now: now, + sessionStart: sessionStart, + history: history, + statusText: 'Sensor nearing end of life', + scenario: MockScenario.expiringSoon, + health: const CgmHealthSnapshot( + statusText: 'Sensor expiring soon', + warningFlagsHex: '0x02', + sensorCheckHex: '0100', + ), + ); + } + + CgmSessionSnapshot _expired() { + final now = _clock(); + // Started just over 15 days ago: expired / session stopped. + final sessionStart = now.subtract( + _sensorLife + const Duration(hours: 2), + ); + // Last readings stop ~2h ago, when the sensor expired. + final lastReadingAt = now.subtract(const Duration(hours: 2)); + final history = _buildHistory( + now: lastReadingAt, + sessionStart: sessionStart, + count: 48, + shape: _normalShape, + ); + final base = _baseSnapshot( + now: now, + sessionStart: sessionStart, + history: history, + stage: CgmSyncStage.ready, + statusText: 'Sensor expired — replace sensor', + scenario: MockScenario.expired, + health: const CgmHealthSnapshot( + statusText: 'Sensor expired', + expired: true, + warningFlagsHex: '0x04', + sensorCheckHex: '0101', + ), + ); + return base.copyWith( + sessionInfo: base.sessionInfo.copyWith(sessionStopped: true), + ); + } + + CgmSessionSnapshot _signalLoss() { + final now = _clock(); + final sessionStart = now.subtract(const Duration(hours: 16)); + // Readings froze ~12 minutes ago (signal lost), session still "connected". + final lastReadingAt = now.subtract(const Duration(minutes: 12)); + final history = _buildHistory( + now: lastReadingAt, + sessionStart: sessionStart, + count: 48, + shape: _normalShape, + ); + return _baseSnapshot( + now: now, + sessionStart: sessionStart, + history: history, + stage: CgmSyncStage.ready, + statusText: 'Signal lost — waiting for sensor', + scenario: MockScenario.signalLoss, + health: const CgmHealthSnapshot( + statusText: 'Signal lost', + signalLost: true, + warningFlagsHex: '0x08', + sensorCheckHex: '0100', + ), + ); + } + + CgmSessionSnapshot _disconnected() { + final now = _clock(); + final sessionStart = now.subtract(const Duration(hours: 16)); + final lastReadingAt = now.subtract(const Duration(minutes: 4)); + final history = _buildHistory( + now: lastReadingAt, + sessionStart: sessionStart, + count: 48, + shape: _normalShape, + ); + return _baseSnapshot( + now: now, + sessionStart: sessionStart, + history: history, + stage: CgmSyncStage.disconnected, + statusText: 'Disconnected', + scenario: MockScenario.disconnected, + health: const CgmHealthSnapshot( + statusText: 'Disconnected', + warningFlagsHex: '0x00', + sensorCheckHex: '0100', + ), + ); + } + + CgmSessionSnapshot _multiSensorHistory() { + final now = _clock(); + final sessionStart = now.subtract(const Duration(hours: 30)); + // Current-sensor readings for the last 30h. + final current = _buildHistory( + now: now, + sessionStart: sessionStart, + count: 60, + shape: _normalShape, + ); + // Previous sensor: a separate older block, ~5-7 days ago, sourced as + // vendor history carried over. Negative sensorMinute marks it as belonging + // to the prior session so the UI can still render a continuous trend. + final previousStart = now.subtract(const Duration(days: 7)); + final previous = List.generate(48, (index) { + final recordedAt = previousStart.add(Duration(minutes: index * 10)); + final value = 110 + (math.sin(index / 5) * 20) + ((index % 4) - 1.5); + return CgmReading( + valueMgdl: value, + source: CgmRecordSource.vendor, + sensorMinute: -(48 - index) * 10, + recordedAt: recordedAt, + rawValue: value.round(), + qualifier: 1, + ); + }); + final history = [...previous, ...current]; + final base = _readySnapshot( + now: now, + sessionStart: sessionStart, + history: history, + statusText: 'Demo session ready (multi-sensor history)', + scenario: MockScenario.multiSensorHistory, + ); + return base.copyWith( + metadata: { + ...base.metadata, + 'previousSensorSerial': '06F90', + 'previousSensorReadings': previous.length.toString(), + }, + ); + } + + CgmSessionSnapshot _error() { + return CgmSessionSnapshot( + stage: CgmSyncStage.error, + statusText: 'Sensor error', + sensor: sensor, + capabilities: sensor.capabilities, + lastAdvertisement: sensor.advertisement, + diagnostics: _buildDiagnostics(), + sessionInfo: const CgmSessionInfo( + manufacturer: 'MicroTech Medical', + model: 'AiDEX X', + serial: '07A12', + firmware: '1.9.7-demo', + warmupMinutes: _warmupMinutes, + ), + health: const CgmHealthSnapshot( + statusText: 'Sensor malfunction', + error: true, + malfunction: true, + warningFlagsHex: '0x80', + sensorCheckHex: '0111', + ), + metadata: { + 'deviceId': sensor.deviceId, + 'serial': '07A12', + 'mode': 'demo', + 'driverId': sensor.driverId, + 'scenario': MockScenario.error.id, + 'historyCount': '0', + }, + lastError: 'Demo: simulated sensor error (E-204 communication failure)', + ); + } + + // --- Shared builders ------------------------------------------------------ + + List _buildHistory({ + required DateTime now, + required DateTime sessionStart, + required int count, + required double Function(int index, int count) shape, + }) { + return List.generate(count, (index) { + final recordedAt = now.subtract( + Duration(minutes: (count - 1 - index) * 5), + ); + final sensorMinute = recordedAt.difference(sessionStart).inMinutes; + final value = shape(index, count); + return CgmReading( + valueMgdl: value, + source: index == count - 1 + ? CgmRecordSource.broadcast + : CgmRecordSource.vendor, + sensorMinute: sensorMinute, + recordedAt: recordedAt, + rawValue: value.round(), + qualifier: 1, + ); + }); + } + + /// A "ready" active snapshot with history, calibrations, and full session + /// info. Used by the active/expiring/multi-sensor scenarios. + CgmSessionSnapshot _readySnapshot({ + required DateTime now, + required DateTime sessionStart, + required List history, + required String statusText, + required MockScenario scenario, + CgmHealthSnapshot health = const CgmHealthSnapshot( + statusText: 'Sensor active', + warningFlagsHex: '0x00', + sensorCheckHex: '0100', + ), + }) { + return _baseSnapshot( + now: now, + sessionStart: sessionStart, + history: history, + stage: CgmSyncStage.ready, + statusText: statusText, + scenario: scenario, + health: health, + ); + } + + CgmSessionSnapshot _baseSnapshot({ + required DateTime now, + required DateTime sessionStart, + required List history, + required CgmSyncStage stage, + required String statusText, + required MockScenario scenario, + required CgmHealthSnapshot health, + CgmReading? latestReading = _unsetReading, + }) { + final resolvedLatest = identical(latestReading, _unsetReading) + ? (history.isEmpty ? null : history.last) + : latestReading; + final calibrations = history.length > 20 + ? [ + CgmCalibrationEntry( + index: 1, + glucoseMgdl: history[20].valueMgdl.round(), + sensorMinute: history[20].sensorMinute, + recordedAt: history[20].recordedAt, + payloadHex: '7600150c', + ), + ] + : const []; + return CgmSessionSnapshot( + stage: stage, + statusText: statusText, + sensor: sensor, + capabilities: sensor.capabilities, + latestReading: resolvedLatest, + lastAdvertisement: sensor.advertisement, + history: history, + rawHistory: history + .map((reading) => reading.copyWith(source: CgmRecordSource.raw)) + .toList(growable: false), + calibrations: calibrations, + diagnostics: _buildDiagnostics(), + sessionInfo: CgmSessionInfo( + manufacturer: 'MicroTech Medical', + model: 'AiDEX X', + serial: '07A12', + firmware: '1.9.7-demo', + sessionStart: sessionStart, + sessionStartPayloadHex: 'E907040D01080C0000', + elapsedMinutes: history.isEmpty + ? now.difference(sessionStart).inMinutes + : history.last.sensorMinute, + warmupMinutes: _warmupMinutes, + ), + health: health, + historySync: CgmHistorySyncState( + storedCount: history.length, + totalAvailable: history.length, + latestStoredOffset: history.isEmpty ? null : history.last.sensorMinute, + startIndex: history.isEmpty ? null : history.first.sensorMinute, + targetIndex: history.isEmpty ? null : history.last.sensorMinute, + lastSyncAt: now, + ), + metadata: { + 'deviceId': sensor.deviceId, + 'serial': '07A12', + 'mode': 'demo', + 'driverId': sensor.driverId, + 'scenario': scenario.id, + 'historyCount': history.length.toString(), + }, + ); + } + + List _buildDiagnostics() { + return [ + const CgmDiagnosticItem( + key: 'pair', + title: 'Vendor Pair State', + summary: 'Demo transport always reports the vendor session as ready.', + rawHex: '1122334455667788', + ), + const CgmDiagnosticItem( + key: 'device', + title: 'Device Information', + fields: { + 'manufacturer': 'MicroTech Medical', + 'model': 'AiDEX X', + 'firmware': '1.9.7-demo', + }, + ), + const CgmDiagnosticItem( + key: 'status', + title: 'Standard CGM State', + fields: { + 'featureHex': '8B3F', + 'statusHex': '0000010000000000', + 'sessionRunTimeHex': '00480000', + }, + ), + ]; + } + + /// Sentinel so [_baseSnapshot] can distinguish "use history.last" from an + /// explicit `null` latest reading (warmup needs the latter). + static const CgmReading _unsetReading = CgmReading( + valueMgdl: double.nan, + source: CgmRecordSource.vendor, + ); +} diff --git a/openhealth/test/mock_scenarios_test.dart b/openhealth/test/mock_scenarios_test.dart new file mode 100644 index 0000000..db499d3 --- /dev/null +++ b/openhealth/test/mock_scenarios_test.dart @@ -0,0 +1,224 @@ +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/display_preferences.dart'; +import 'package:openglucose/src/mock_scenarios.dart'; +import 'package:openglucose/src/session_presentation.dart'; + +/// Fixed clock so scenario snapshots are deterministic. +final _now = DateTime.utc(2026, 6, 23, 12); +DateTime _clock() => _now; + +void main() { + group('MockScenario.fromId', () { + test('resolves every known id back to its scenario', () { + for (final scenario in MockScenario.values) { + expect(MockScenario.fromId(scenario.id), scenario); + } + }); + + test('falls back to activeNormal for empty/unknown ids', () { + expect(MockScenario.fromId(null), MockScenario.activeNormal); + expect(MockScenario.fromId(''), MockScenario.activeNormal); + expect(MockScenario.fromId('nope'), MockScenario.activeNormal); + }); + }); + + group('MockScenarioCatalog.buildSnapshot', () { + final catalog = MockScenarioCatalog(clock: _clock); + + test('builds a snapshot for every scenario and tags metadata', () { + for (final scenario in MockScenario.values) { + final snapshot = catalog.buildSnapshot(scenario); + expect( + snapshot.metadata['scenario'], + scenario.id, + reason: '${scenario.id} should tag its metadata', + ); + expect(snapshot.metadata['mode'], 'demo'); + } + }); + + test('warmup has no readings and is inside the warmup window', () { + final snapshot = catalog.buildSnapshot(MockScenario.warmup); + expect(snapshot.history, isEmpty); + expect(snapshot.latestReading, isNull); + final warmup = computeWarmupStatus(snapshot, now: _now); + expect(warmup, isNotNull); + expect(warmup!.phase, WarmupPhase.warming); + expect(warmup.remainingMinutes, greaterThan(0)); + }); + + test('activeNormal stays in range (70-180 mg/dL)', () { + final snapshot = catalog.buildSnapshot(MockScenario.activeNormal); + expect(snapshot.history, isNotEmpty); + for (final reading in snapshot.history) { + expect(reading.valueMgdl, greaterThanOrEqualTo(70)); + expect(reading.valueMgdl, lessThanOrEqualTo(180)); + } + }); + + test('activeHigh sustains above the high threshold', () { + final snapshot = catalog.buildSnapshot(MockScenario.activeHigh); + expect(snapshot.latestReading, isNotNull); + expect( + snapshot.latestReading!.valueMgdl, + greaterThan(kMockHighThresholdMgdl), + ); + // The bulk of the window should be high, not just the last point. + final highCount = snapshot.history + .where((r) => r.valueMgdl > kMockHighThresholdMgdl) + .length; + expect(highCount, greaterThan(snapshot.history.length ~/ 2)); + }); + + test('activeLow sustains below the low threshold', () { + final snapshot = catalog.buildSnapshot(MockScenario.activeLow); + expect(snapshot.latestReading, isNotNull); + expect( + snapshot.latestReading!.valueMgdl, + lessThan(kMockLowThresholdMgdl), + ); + final lowCount = snapshot.history + .where((r) => r.valueMgdl < kMockLowThresholdMgdl) + .length; + expect(lowCount, greaterThan(snapshot.history.length ~/ 2)); + }); + + test('rapidRise trends strongly upward', () { + final snapshot = catalog.buildSnapshot(MockScenario.rapidRise); + final first = snapshot.history.first.valueMgdl; + final last = snapshot.history.last.valueMgdl; + expect(last - first, greaterThan(100)); + final trend = glucoseTrendSummary( + snapshot.history, + const DisplayPreferences(), + ); + expect(trend.symbol, anyOf('↑', '↑↑', '↗')); + }); + + test('rapidFall trends strongly downward', () { + final snapshot = catalog.buildSnapshot(MockScenario.rapidFall); + final first = snapshot.history.first.valueMgdl; + final last = snapshot.history.last.valueMgdl; + expect(first - last, greaterThan(100)); + final trend = glucoseTrendSummary( + snapshot.history, + const DisplayPreferences(), + ); + expect(trend.symbol, anyOf('↓', '↓↓', '↘')); + }); + + test('expiringSoon reports hours, not days, of life left', () { + final snapshot = catalog.buildSnapshot(MockScenario.expiringSoon); + final lifeText = sensorLifeText( + snapshot.sessionInfo.sessionStart, + now: _now, + ); + expect(lifeText, contains('hour')); + expect(snapshot.history, isNotEmpty); + }); + + test('expired is past 15-day life, stopped, and flagged expired', () { + final snapshot = catalog.buildSnapshot(MockScenario.expired); + expect(snapshot.sessionInfo.sessionStopped, isTrue); + expect(snapshot.health.expired, isTrue); + final lifeText = sensorLifeText( + snapshot.sessionInfo.sessionStart, + now: _now, + ); + expect(lifeText, 'Sensor expired'); + }); + + test('signalLoss is connected with a stale last reading', () { + final snapshot = catalog.buildSnapshot(MockScenario.signalLoss); + expect(snapshot.stage, CgmSyncStage.ready); + expect(snapshot.health.signalLost, isTrue); + final recordedAt = snapshot.latestReading!.recordedAt!; + expect(_now.difference(recordedAt).inMinutes, greaterThanOrEqualTo(10)); + }); + + test('disconnected uses the disconnected stage but keeps data', () { + final snapshot = catalog.buildSnapshot(MockScenario.disconnected); + expect(snapshot.stage, CgmSyncStage.disconnected); + expect(snapshot.history, isNotEmpty); + expect(stageLabelForSnapshot(snapshot), 'Reconnecting'); + }); + + test('multiSensorHistory carries previous-sensor readings', () { + final snapshot = catalog.buildSnapshot(MockScenario.multiSensorHistory); + expect(snapshot.metadata['previousSensorReadings'], isNotNull); + // Readings with negative sensorMinute belong to the previous sensor. + final previous = snapshot.history.where( + (r) => (r.sensorMinute ?? 0) < 0, + ); + expect(previous, isNotEmpty); + final current = snapshot.history.where((r) => (r.sensorMinute ?? 0) >= 0); + expect(current, isNotEmpty); + }); + + test('error has no data, error stage, and a lastError message', () { + final snapshot = catalog.buildSnapshot(MockScenario.error); + expect(snapshot.stage, CgmSyncStage.error); + expect(snapshot.history, isEmpty); + expect(snapshot.latestReading, isNull); + expect(snapshot.health.error, isTrue); + expect(snapshot.lastError, isNotNull); + expect(shouldShowPrimaryError(snapshot), isTrue); + }); + }); + + group('DemoCgmSession scenario switching', () { + test('applyScenario emits a fresh snapshot for the new scenario', () async { + final driver = DemoCgmDriver( + initialScenario: MockScenario.activeNormal, + clock: _clock, + ); + final session = await driver.connect(driver.scenarioSensor) + as DemoCgmSession; + addTearDown(session.disconnect); + + expect(session.scenario, MockScenario.activeNormal); + expect(session.currentSnapshot.stage, CgmSyncStage.ready); + + final emitted = session.snapshots.first; + session.applyScenario(MockScenario.activeHigh); + + final snapshot = await emitted; + expect(session.scenario, MockScenario.activeHigh); + expect(snapshot.metadata['scenario'], MockScenario.activeHigh.id); + expect( + snapshot.latestReading!.valueMgdl, + greaterThan(kMockHighThresholdMgdl), + ); + }); + + test('driver.applyScenario updates the live session', () async { + final driver = DemoCgmDriver(clock: _clock); + final session = await driver.connect(driver.scenarioSensor) + as DemoCgmSession; + addTearDown(session.disconnect); + + final result = driver.applyScenario(MockScenario.error); + expect(result, same(session)); + expect(driver.scenario, MockScenario.error); + expect(session.currentSnapshot.stage, CgmSyncStage.error); + }); + + test('refresh keeps a high scenario in its glucose band', () async { + final driver = DemoCgmDriver( + initialScenario: MockScenario.activeHigh, + clock: _clock, + ); + final session = await driver.connect(driver.scenarioSensor) + as DemoCgmSession; + addTearDown(session.disconnect); + + await session.refresh(); + expect( + session.currentSnapshot.latestReading!.valueMgdl, + greaterThan(kMockHighThresholdMgdl), + ); + }); + }); +} diff --git a/openhealth/test/og_demo_flag_test.dart b/openhealth/test/og_demo_flag_test.dart new file mode 100644 index 0000000..ac56312 --- /dev/null +++ b/openhealth/test/og_demo_flag_test.dart @@ -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()); + } else { + expect(driver, isA()); + expect(driver, isNot(isA())); + } + }); + }); +} diff --git a/openhealth/test/widget_test.dart b/openhealth/test/widget_test.dart index 5f7c8a2..a66fef2 100644 --- a/openhealth/test/widget_test.dart +++ b/openhealth/test/widget_test.dart @@ -3,11 +3,66 @@ import 'dart:convert'; import 'package:openglucose/main.dart'; import 'package:openglucose/src/app_controller.dart'; import 'package:openglucose/src/demo_driver.dart'; +import 'package:openglucose/src/mock_scenarios.dart'; import 'package:cgm_core/cgm_core.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { + testWidgets('developer tab scenario picker switches the live dashboard', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final preferences = await SharedPreferences.getInstance(); + final controller = CgmAppController( + preferences: preferences, + driver: DemoCgmDriver(), + ); + await controller.initialize(); + + await tester.pumpWidget(OpenGlucoseApp(controller: controller)); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + await tester.tap(find.text('Find my sensor')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + expect(controller.mockScenario, MockScenario.activeNormal); + + // Open settings -> Developer tab. + await tester.tap(find.byIcon(Icons.tune_rounded)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Developer')); + await tester.pumpAndSettle(); + + // The scenario picker is present (only shown for the mock driver). + final picker = find.byKey(const ValueKey('mockScenarioPicker')); + expect(picker, findsOneWidget); + + // Switch to activeHigh and confirm the controller + snapshot updated. + await tester.tap(picker); + await tester.pumpAndSettle(); + await tester.tap(find.text('Active — high (alert)').last); + await tester.pumpAndSettle(); + + expect(controller.mockScenario, MockScenario.activeHigh); + expect( + controller.latestReading!.valueMgdl, + greaterThan(kMockHighThresholdMgdl), + ); + expect( + controller.snapshot!.metadata['scenario'], + MockScenario.activeHigh.id, + ); + + // Dismiss the settings sheet, then unmount the tree so the dashboard's + // periodic hero-card ticker is disposed before teardown. + Navigator.of(tester.element(picker)).pop(); + await tester.pumpAndSettle(); + await tester.pumpWidget(const SizedBox.shrink()); + await controller.disconnect(); + }); + testWidgets('demo driver renders scan results and dashboard', (tester) async { SharedPreferences.setMockInitialValues({}); final preferences = await SharedPreferences.getInstance();