Skip to content
Open
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
6 changes: 5 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
"--dart-define",
"deployment-mode=test",
"--dart-define",
"demo=true",
"--dart-define",
"debug-level=none"
],
"flutterMode": "release"
Expand All @@ -75,7 +77,9 @@
"--dart-define",
"deployment-mode=test",
"--dart-define",
"debug-level=debug"
"demo=true",
"--dart-define",
"debug-level=none"
],
"flutterMode": "debug"
},
Expand Down
Binary file added assets/logo_mark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/logo_word.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion lib/carp_study_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ class CarpAppState extends State<CarpStudyApp> {
locale: AppConfig.localization?.locale,
theme: carpTheme,
themeMode: ThemeMode.light,
debugShowCheckedModeBanner: true,
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
Expand Down
4 changes: 4 additions & 0 deletions lib/helpers/app_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ abstract class AppConfig {
(mode) => mode.name == const String.fromEnvironment('deployment-mode', defaultValue: 'production'),
);

/// Whether to show generated demo data instead of (missing) real sensor
/// readings - for demos and promo videos. See [DemoDataService].
static bool demoMode = const bool.fromEnvironment('demo');

/// Debug level for the app and CAMS.
static DebugLevel debugLevel = DebugLevel.values.firstWhere(
(level) => level.name == const String.fromEnvironment('debug-level', defaultValue: 'info'),
Expand Down
1 change: 1 addition & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ part 'services/study_service.dart';
part 'services/message_service.dart';
part 'services/consent_service.dart';
part 'services/data_stream_query_service.dart';
part 'services/demo_data_service.dart';
part 'services/background_sensing_service.dart';

part 'data/local_settings.dart';
Expand Down
207 changes: 207 additions & 0 deletions lib/services/demo_data_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
part of carp_study_app;

/// Generated sensor data for demos and promo videos: a week of history on the
/// statistics cards, plus fresh measurements every [interval] so the cards move
/// on screen. Enabled with `--dart-define=demo=true`, off otherwise.
///
/// Feeds the same paths real sensors do - [measurements] stands in for the
/// study controller's stream, and the week is handed to the cards'
/// `addMeasurements`, so nothing about the cards themselves is demo-aware.
class DemoDataService {
static final DemoDataService _instance = DemoDataService._();
factory DemoDataService() => _instance;
DemoDataService._();

/// How often live measurements are generated.
static const Duration interval = Duration(seconds: 5);

/// The measures this generator stands in for, so their cards show up even
/// when the deployment collects none of them. Movesense is left out - one
/// heart rate card, not two showing the same beats.
static const Set<String> demoMeasures = {
PolarSamplingPackage.HR,
SensorSamplingPackage.STEP_EVENT,
ContextSamplingPackage.ACTIVITY,
ContextSamplingPackage.MOBILITY,
HealthSamplingPackage.HEALTH,
};

/// Whether the generator produces [type] in place of a real sensor.
static bool covers(String type) => AppConfig.demoMode && demoMeasures.contains(type);

/// Fixed seed - every take of the promo video shows the same data.
final Random _random = Random(42);

final StreamController<Measurement> _measurements = StreamController.broadcast();
Timer? _timer;

/// The generated live measurements, standing in for the sensor streams.
Stream<Measurement> get measurements => _measurements.stream;

/// Today's running totals, continued by the live ticks so the figures only
/// grow from where the backfilled week left them.
int _steps = 0;
double _distance = 0;
int _places = 3;
double _homeStay = 0.5;

/// Fill [model]'s cards with the last week and start the live stream.
/// Idempotent - a second call is a no-op, so revisiting the page never wipes
/// what the live ticks have added.
void start(StatisticsViewModel model) {
if (_timer != null) return;
final now = DateTime.now();

model.polarHeartRateCardDataModel.addMeasurements(_heartRates(now));
model.stepsCardDataModel.addMeasurements(_stepCounts(now));
model.activityCardDataModel.addMeasurements(_activities(now));
model.mobilityCardDataModel.addMeasurements(_mobility(now));
model.sleepCardDataModel.addMeasurements(_sleep(now));

_timer = Timer.periodic(interval, (_) => tick());
}

void stop() {
_timer?.cancel();
_timer = null;
}

/// Emit one round of live measurements: a heart beat, a few more steps and a
/// little more distance travelled today.
///
/// ponytail: no activity here - the card measures an activity by the gap to
/// the next, *different* one, so ticks 5 s apart would all be 0 minutes.
void tick() {
final now = DateTime.now();
_steps += 5 + _random.nextInt(25);
_distance += 20 + _random.nextInt(60);

_measurements.add(_at(now, PolarHR(samples: [_hrSample(_bpm(now))])));
_measurements.add(_at(now, StepEvent(steps: _steps)));
_measurements.add(_at(now, _mobilityOn(DateUtils.dateOnly(now))));
}

/// A measurement of [data] taken [at] - the sensor time the cards bucket by.
Measurement _at(DateTime at, Data data) => Measurement.fromData(data, at.microsecondsSinceEpoch);

/// Midnight of the 7 days ending today, oldest first.
List<DateTime> _week(DateTime now) =>
List.generate(7, (i) => DateUtils.dateOnly(now).subtract(Duration(days: 6 - i)));

/// A plausible heart rate at [at]: a resting baseline dipping at night and
/// peaking mid-afternoon, with an evening workout on top.
double _bpm(DateTime at) {
final hour = at.hour + at.minute / 60;
final workout = (hour >= 17.5 && hour < 18.5) ? 45 : 0;
return (62 + 12 * sin((hour - 8) / 24 * 2 * pi) + workout + _random.nextInt(9) - 4).roundToDouble();
}

PolarHRSample _hrSample(double bpm) =>
PolarHRSample(hr: bpm.round(), rrsMs: [(60000 / bpm).round()], contactStatus: true, contactStatusSupported: true);

/// A heart rate every 15 minutes of the last week, up to now.
List<Measurement> _heartRates(DateTime now) => [
for (final at in _every(const Duration(minutes: 15), from: _week(now).first, until: now))
_at(at, PolarHR(samples: [_hrSample(_bpm(at))])),
];

/// The waking day the step and activity generators fill in.
static const Duration _wakeUp = Duration(hours: 7);
static const Duration _bedTime = Duration(hours: 23);

/// The pedometer's running total every half hour of the waking day. The total
/// is only meaningful within a day - the card resets its baseline at midnight,
/// as a real pedometer does on reboot.
List<Measurement> _stepCounts(DateTime now) => [
for (final day in _week(now))
for (final at in _every(
const Duration(minutes: 30),
from: day.add(_wakeUp),
until: _earliest(day.add(_bedTime), now),
))
_at(at, StepEvent(steps: _steps += 100 + _random.nextInt(500))),
];

/// A day of activity transitions, as minutes spent doing each - the card
/// times an activity by the gap to the next one, so every entry both ends the
/// previous activity and starts its own.
static const List<(ActivityType, int)> _dayPlan = [
(ActivityType.STILL, 0),
(ActivityType.WALKING, 25),
(ActivityType.STILL, 220),
(ActivityType.ON_BICYCLE, 20),
(ActivityType.STILL, 90),
(ActivityType.RUNNING, 35),
(ActivityType.STILL, 60),
(ActivityType.WALKING, 30),
(ActivityType.STILL, 0),
];

List<Measurement> _activities(DateTime now) {
final measurements = <Measurement>[];
for (final day in _week(now)) {
var at = day.add(_wakeUp + const Duration(minutes: 30));
for (final (type, minutes) in _dayPlan) {
at = at.add(Duration(minutes: minutes));
if (at.isAfter(now)) break;
measurements.add(_at(at, Activity(type: type, confidence: 100)));
}
}
return measurements;
}

/// One mobility summary per day - the card keeps the latest reading per day,
/// which the live ticks keep raising for today.
List<Measurement> _mobility(DateTime now) {
final measurements = <Measurement>[];
for (final day in _week(now)) {
_places = 2 + _random.nextInt(5);
_homeStay = 0.35 + _random.nextInt(40) / 100;
_distance = 3000 + _random.nextInt(12000).toDouble();
measurements.add(_at(_earliest(day.add(_bedTime), now), _mobilityOn(day)));
}
return measurements;
}

Mobility _mobilityOn(DateTime date) => Mobility(
date: date,
numberOfStops: _places + 2,
numberOfMoves: _places,
numberOfPlaces: _places,
homeStay: _homeStay,
distanceTraveled: _distance,
);

/// A night's sleep per day, in stages, ending on the morning it is charted
/// on - tonight's sleep has not happened yet, so the week ends yesterday.
List<Measurement> _sleep(DateTime now) => [
for (final morning in _week(now).map((day) => day.add(_wakeUp)))
if (morning.isBefore(now))
for (final (type, minutes) in [
('SLEEP_DEEP', 70 + _random.nextInt(40)),
('SLEEP_LIGHT', 200 + _random.nextInt(60)),
('SLEEP_REM', 60 + _random.nextInt(40)),
])
_at(
morning,
HealthData(
uuid: '$type-${morning.toIso8601String()}',
value: NumericHealthValue(numericValue: minutes),
unit: 'MINUTES',
healthDataType: type,
dateFrom: morning.subtract(Duration(minutes: minutes)),
dateTo: morning,
platform: HealthPlatform.APPLE_HEALTH,
),
),
];

/// Every [step] from [from] up to [until], exclusive.
Iterable<DateTime> _every(Duration step, {required DateTime from, required DateTime until}) sync* {
for (var at = from; at.isBefore(until); at = at.add(step)) {
yield at;
}
}

static DateTime _earliest(DateTime a, DateTime b) => a.isBefore(b) ? a : b;
}
2 changes: 1 addition & 1 deletion lib/view_models/cards/activity_data_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class ActivityCardViewModel extends SerializableViewModel<WeeklyActivities> {

/// Stream of activity measurements.
Stream<Measurement>? get activityEvents =>
controller?.measurements.where((measurement) => measurement.data is Activity);
measurements?.where((measurement) => measurement.data is Activity);

@override
void init(SmartphoneStudyController ctrl) {
Expand Down
2 changes: 1 addition & 1 deletion lib/view_models/cards/heart_rate_data_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class HeartRateCardViewModel extends SerializableViewModel<HourlyHeartRate> {

/// Stream of measurements of this card's [dataType] only.
Stream<Measurement>? get sourceStream =>
controller?.measurements.where((measurement) => measurement.dataType.toString() == dataType);
measurements?.where((measurement) => measurement.dataType.toString() == dataType);

/// Stream of heart rate readings in BPM, for the card to rebuild on.
Stream<double>? get heartRateStream =>
Expand Down
4 changes: 2 additions & 2 deletions lib/view_models/cards/measurements_data_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ class MeasurementsCardViewModel extends ViewModel {
final Map<String, int> _samplingTable = {};

/// Stream of [Measurement] measures.
Stream<Measurement>? get measureEvents => controller?.measurements;
Stream<Measurement>? get measureEvents => measurements;

/// Stream of more quiet [DataPoint] measures.
Stream<Measurement>? get quietMeasureEvents =>
controller?.measurements.where((measurement) => measurement.dataType.name != 'sensor');
measurements?.where((measurement) => measurement.dataType.name != 'sensor');

/// The total sampling size, derived from the per-type counts in [samplingTable].
int get samplingSize => _samplingTable.values.fold(0, (sum, count) => sum + count);
Expand Down
2 changes: 1 addition & 1 deletion lib/view_models/cards/mobility_data_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class MobilityCardViewModel extends SerializableViewModel<WeeklyMobility> {

/// Stream of mobility [Measurement]s.
Stream<Measurement>? get mobilityEvents =>
controller?.measurements.where((measurement) => measurement.data is Mobility);
measurements?.where((measurement) => measurement.data is Mobility);

@override
void init(SmartphoneStudyController ctrl) {
Expand Down
2 changes: 1 addition & 1 deletion lib/view_models/cards/sleep_data_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class SleepCardViewModel extends SerializableViewModel<WeeklySleep> {

/// Stream of health measurements carrying sleep.
Stream<Measurement>? get sleepEvents =>
controller?.measurements.where((measurement) => _minutesOf(measurement.data) != null);
measurements?.where((measurement) => _minutesOf(measurement.data) != null);

/// The minutes of sleep in [data], or null if it is not a sleep reading.
static double? _minutesOf(Data data) {
Expand Down
3 changes: 2 additions & 1 deletion lib/view_models/cards/steps_data_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class StepsCardViewModel extends SerializableViewModel<WeeklySteps> {

/// Stream of pedometer (step) [DataPoint] measures.
Stream<Measurement>? get pedometerEvents =>
controller?.measurements.where((measurement) => _stepsOf(measurement.data) != null);
measurements?.where((measurement) => _stepsOf(measurement.data) != null);

@override
void init(SmartphoneStudyController ctrl) {
Expand All @@ -50,6 +50,7 @@ class StepsCardViewModel extends SerializableViewModel<WeeklySteps> {
// listen for pedometer events and count them
pedometerEvents?.listen((measurement) {
_lastStep = _addStepCount(model, measurement, _lastStep);
notifyListeners();
}, onError: onMeasurementStreamError);
}

Expand Down
21 changes: 16 additions & 5 deletions lib/view_models/statistics_view_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,17 @@ class StatisticsViewModel extends ViewModel {
super.init(ctrl);

_hasUserTasks = _study.hasUserTasks();
_hasPolarHeartRateMeasure = _study.hasMeasure(PolarSamplingPackage.HR);
_hasPolarHeartRateMeasure = _hasMeasure(PolarSamplingPackage.HR);
_hasMovesenseHeartRateMeasure = _study.hasMeasure(MovesenseSamplingPackage.HR);
_hasAudioMeasure = _study.hasMeasure(MediaSamplingPackage.AUDIO);
_hasVideoMeasure = _study.hasMeasure(MediaSamplingPackage.VIDEO);
_hasImageMeasure = _study.hasMeasure(MediaSamplingPackage.IMAGE);
_hasStepsMeasure = StepsCardViewModel.dataTypes.any(_study.hasMeasure);
_hasActivityMeasure = _study.hasMeasure(ContextSamplingPackage.ACTIVITY);
_hasMobilityMeasure = _study.hasMeasure(ContextSamplingPackage.MOBILITY);
_hasStepsMeasure = StepsCardViewModel.dataTypes.any(_hasMeasure);
_hasActivityMeasure = _hasMeasure(ContextSamplingPackage.ACTIVITY);
_hasMobilityMeasure = _hasMeasure(ContextSamplingPackage.MOBILITY);
// A health measure may or may not include sleep types, but the card's
// hasData gate hides it either way until sleep actually arrives.
_hasSleepMeasure = _study.hasMeasure(HealthSamplingPackage.HEALTH);
_hasSleepMeasure = _hasMeasure(HealthSamplingPackage.HEALTH);

_activityCardDataModel.init(ctrl);
_stepsCardDataModel.init(ctrl);
Expand All @@ -108,12 +108,22 @@ class StatisticsViewModel extends ViewModel {
_studyProgressCardDataModel.init(ctrl);
}

/// Whether the deployment collects [type] - or the demo generator stands in
/// for it, so its card shows up in a demo of a study that does not collect it.
bool _hasMeasure(String type) => _study.hasMeasure(type) || DemoDataService.covers(type);

/// Fetch the last 7 days from CAWS and recompute the cards. Best-effort:
/// a failed fetch leaves existing card data untouched. No-op while running.
Future<void> refresh() async {
if (_isRefreshing) return;
_isRefreshing = true;

if (AppConfig.demoMode) {
DemoDataService().start(this);
_isRefreshing = false;
return;
}

try {
await Future.wait([
// A study declares one of the two pedometer types (legacy STEP_COUNT
Expand Down Expand Up @@ -188,6 +198,7 @@ class StatisticsViewModel extends ViewModel {

@override
void dispose() {
DemoDataService().stop();
_activityCardDataModel.dispose();
_stepsCardDataModel.dispose();
_polarHeartRateCardDataModel.dispose();
Expand Down
6 changes: 6 additions & 0 deletions lib/view_models/view_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ abstract class ViewModel extends ChangeNotifier {

SmartphoneStudyController? get controller => _controller;

/// The measurements the cards source from - generated ones in demo mode,
/// which stands in for the sensors entirely rather than mixing with them.
@protected
Stream<Measurement>? get measurements =>
AppConfig.demoMode ? DemoDataService().measurements : controller?.measurements;

/// Initialize this view model before use.
@mustCallSuper
void init(SmartphoneStudyController ctrl) {
Expand Down
4 changes: 4 additions & 0 deletions promo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
out
public/app.mp4
public/vo/music.mp3
Loading