diff --git a/designer_v2/lib/common_views/utils.dart b/designer_v2/lib/common_views/utils.dart index dff1af1e5..2c729e3fd 100644 --- a/designer_v2/lib/common_views/utils.dart +++ b/designer_v2/lib/common_views/utils.dart @@ -1,4 +1,6 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:studyu_designer_v2/theme.dart'; +import 'package:studyu_designer_v2/utils/extensions.dart'; typedef WidgetDecorator = Widget Function(Widget widget); @@ -43,3 +45,18 @@ extension ColorX on Color { return withAlpha((alphaScaleFactor * alpha).round()); } } + +Widget interventionPrefix(int rowIdx, BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Text( + ''.alphabetLetterFrom(rowIdx).toUpperCase(), + style: TextStyle( + color: ThemeConfig.dropdownMenuItemTheme(theme).iconTheme!.color, + ), + ), + const SizedBox(width: 16.0), + ], + ); +} diff --git a/designer_v2/lib/constants.dart b/designer_v2/lib/constants.dart index f84ac181e..7e484948f 100644 --- a/designer_v2/lib/constants.dart +++ b/designer_v2/lib/constants.dart @@ -19,6 +19,9 @@ class Config { static const minSplashTime = 0; static const formAutosaveDebounce = 1000; + + static const participantDropoutDuration = 5; + static const participantInactiveDuration = 3; } const kPathSeparator = ' / '; @@ -46,5 +49,3 @@ const String signupRouteName = 'signup'; const String forgotPasswordRouteName = 'forgotPassword'; const String recoverPasswordRouteName = 'recoverPassword'; const String errorRouteName = 'error'; -const participantDropoutDuration = 5; -const participantInactiveDuration = 3; diff --git a/designer_v2/lib/domain/study_monitoring.dart b/designer_v2/lib/domain/study_monitoring.dart index 51b9e192c..e80d27c7c 100644 --- a/designer_v2/lib/domain/study_monitoring.dart +++ b/designer_v2/lib/domain/study_monitoring.dart @@ -1,39 +1,12 @@ import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:studyu_core/core.dart'; import 'package:studyu_designer_v2/constants.dart'; -class StudyMonitorData { - /// Number of participants who are currently active in the study - /// Active means that the the study has not ended yet and the participant did not drop out - final int activeParticipants; - - // Number of participants who are currently inactive in the study for more than 3 days in a row - final int inactiveParticipants; - - /// Number of participants who dropped out of the study before the study ended - /// Hint: The is_deleted flag in the study_subject database table marks a participant as dropped out - /// Note: If the participant's last activity exceeds 7 days, they will also be counted as a dropout - final int dropoutParticipants; - - /// Number of participants who completed the study - /// Completed means that the participant has reached the end of the study - final int completedParticipants; - - /// List of all participants with their monitoring data - final List items; - - const StudyMonitorData({ - required this.activeParticipants, - required this.inactiveParticipants, - required this.dropoutParticipants, - required this.completedParticipants, - required this.items, - }); -} - class StudyMonitorItem extends Equatable { + final StudySubject studySubject; final String participantId; final String? inviteCode; final DateTime startedAt; @@ -49,6 +22,7 @@ class StudyMonitorItem extends Equatable { final List> completedTasksPerDay; const StudyMonitorItem({ + required this.studySubject, required this.participantId, required this.inviteCode, required this.startedAt, @@ -70,7 +44,7 @@ class StudyMonitorItem extends Equatable { } extension StudyMonitoringX on Study { - StudyMonitorData get monitorData { + List get monitorData { final List items = []; final participants = this.participants ?? []; @@ -158,6 +132,7 @@ extension StudyMonitoringX on Study { items.add( StudyMonitorItem( + studySubject: participant, participantId: participant.id, inviteCode: participant.inviteCode, startedAt: participant.startedAt!, @@ -175,50 +150,59 @@ extension StudyMonitoringX on Study { ); } - int activeParticipants = 0; - int inactiveParticipants = 0; - int dropoutParticipants = 0; - int completedParticipants = 0; - - final participantInactiveDays = DateTime.now() - .subtract(const Duration(days: participantInactiveDuration)); - final participantDropoutDays = DateTime.now() - .subtract(const Duration(days: participantDropoutDuration)); - - for (final item in items) { - if (!item.droppedOut) { - if (item.currentDayOfStudy < item.studyDurationInDays) { - if (item.lastActivityAt.isAfter(participantInactiveDays)) { - activeParticipants += 1; // Active - } else { - if (item.lastActivityAt.isBefore(participantDropoutDays)) { - dropoutParticipants += 1; //dropout - } else { - inactiveParticipants += 1; // Inactive - } - } - } else { - completedParticipants += 1; // Completed - } - } else { - dropoutParticipants += 1; // Dropout - } - } + final participantCategories = items.activeParticipants.toList() + + items.inactiveParticipants.toList() + + items.dropoutParticipants.toList() + + items.completedParticipants.toList(); + final deepEq = const DeepCollectionEquality.unordered().equals; + assert(deepEq(items, participantCategories)); - assert( - activeParticipants + - inactiveParticipants + - dropoutParticipants + - completedParticipants == - items.length, - ); - - return StudyMonitorData( - activeParticipants: activeParticipants, - inactiveParticipants: inactiveParticipants, - dropoutParticipants: dropoutParticipants, - completedParticipants: completedParticipants, - items: items, - ); + return items; } } + +extension ListX on List { + static final inactiveDate = DateTime.now() + .subtract(const Duration(days: Config.participantInactiveDuration)); + static final dropoutDate = DateTime.now() + .subtract(const Duration(days: Config.participantDropoutDuration)); + + static bool Function(StudyMonitorItem p) get studyStillRunning => + (StudyMonitorItem p) => p.currentDayOfStudy < p.studyDurationInDays; + + static bool Function(StudyMonitorItem p) get inactive => (p) => + p.lastActivityAt.isBefore(inactiveDate) && + p.lastActivityAt.isAfter(dropoutDate); + + static bool Function(StudyMonitorItem p) get dropout => + (p) => p.droppedOut || dropoutByDuration(p) && studyStillRunning(p); + + static bool Function(StudyMonitorItem p) get dropoutByDuration => + (p) => p.lastActivityAt.isBefore(dropoutDate); + + /// Number of participants who are currently active in the study + /// Active means that the the study has not ended yet and the participant + /// did not drop out + Iterable get activeParticipants => + where((p) => !dropout(p) && !inactive(p) && studyStillRunning(p)); + + /// Number of participants who are currently inactive in the study for more + /// than [participantDropoutDuration] days in a row + Iterable get inactiveParticipants => where( + (p) => !dropout(p) && inactive(p) && studyStillRunning(p), + ); + + /// Number of participants who dropped out of the study before the study ended + /// Hint: The is_deleted flag in the study_subject database table marks a + /// participant as dropped out + /// Note: If the participant's last activity exceeds + /// [participantDropoutDuration] days, they will also be counted as a dropout + Iterable get dropoutParticipants => where( + (p) => dropout(p), + ); + + /// Number of participants who completed the study + /// Completed means that the participant has reached the end of the study + Iterable get completedParticipants => + where((p) => !dropout(p) && !studyStillRunning(p)); +} diff --git a/designer_v2/lib/features/design/interventions/interventions_form_view.dart b/designer_v2/lib/features/design/interventions/interventions_form_view.dart index bedf14852..bc11a1728 100644 --- a/designer_v2/lib/features/design/interventions/interventions_form_view.dart +++ b/designer_v2/lib/features/design/interventions/interventions_form_view.dart @@ -5,6 +5,7 @@ import 'package:studyu_core/core.dart'; import 'package:studyu_designer_v2/common_views/async_value_widget.dart'; import 'package:studyu_designer_v2/common_views/text_hyperlink.dart'; import 'package:studyu_designer_v2/common_views/text_paragraph.dart'; +import 'package:studyu_designer_v2/common_views/utils.dart'; import 'package:studyu_designer_v2/features/design/interventions/intervention_form_controller.dart'; import 'package:studyu_designer_v2/features/design/interventions/study_schedule_form_view.dart'; import 'package:studyu_designer_v2/features/design/study_design_page_view.dart'; @@ -13,7 +14,6 @@ import 'package:studyu_designer_v2/features/forms/form_array_table.dart'; import 'package:studyu_designer_v2/features/study/study_controller.dart'; import 'package:studyu_designer_v2/localization/app_translation.dart'; import 'package:studyu_designer_v2/theme.dart'; -import 'package:studyu_designer_v2/utils/extensions.dart'; class StudyDesignInterventionsFormView extends StudyDesignPageWidget { const StudyDesignInterventionsFormView(super.studyId, {super.key}); @@ -21,8 +21,6 @@ class StudyDesignInterventionsFormView extends StudyDesignPageWidget { @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(studyControllerProvider(studyId)); - final theme = Theme.of(context); - return AsyncValueWidget( value: state.study, data: (study) { @@ -68,22 +66,8 @@ class StudyDesignInterventionsFormView extends StudyDesignPageWidget { emptyDescription: tr.form_array_interventions_empty_description, hideLeadingTrailingWhenEmpty: true, - rowPrefix: (context, viewModel, rowIdx) { - return Row( - children: [ - Text( - ''.alphabetLetterFrom(rowIdx).toUpperCase(), - style: TextStyle( - color: - ThemeConfig.dropdownMenuItemTheme(theme) - .iconTheme! - .color, - ), - ), - const SizedBox(width: 16.0), - ], - ); - }, + rowPrefix: (context, viewModel, rowIdx) => + interventionPrefix(rowIdx, context), ); }, ); diff --git a/designer_v2/lib/features/monitor/participant_details_view.dart b/designer_v2/lib/features/monitor/participant_details_view.dart index a5540ecd2..ab6aa9bbb 100644 --- a/designer_v2/lib/features/monitor/participant_details_view.dart +++ b/designer_v2/lib/features/monitor/participant_details_view.dart @@ -11,18 +11,19 @@ import 'package:studyu_designer_v2/localization/locale_providers.dart'; import 'package:studyu_designer_v2/utils/extensions.dart'; class ParticipantDetailsView extends ConsumerWidget { - const ParticipantDetailsView({ + ParticipantDetailsView({ required this.monitorItem, - required this.interventions, - required this.observations, - required this.studySchedule, + required this.study, super.key, - }); + }) : studySchedule = study.schedule, + interventions = study.interventions, + observations = study.observations; final StudyMonitorItem monitorItem; + final Study study; + final StudySchedule studySchedule; final List interventions; final List observations; - final StudySchedule studySchedule; static const Color incompleteColor = Color.fromARGB(255, 234, 234, 234); // Add transparency to increase the readability of the text @@ -47,7 +48,7 @@ class ParticipantDetailsView extends ConsumerWidget { text: tr.participant_details_study_days_description, ), const SizedBox(height: 16.0), - _buildPerDayStatus(), + _buildPerDayStatus(context), const SizedBox(height: 16.0), _buildColorLegend(), ], @@ -101,9 +102,8 @@ class ParticipantDetailsView extends ConsumerWidget { ); } - Widget _buildPerDayStatus() { - final int totalCompletedDays = monitorItem.missedTasksPerDay.length; - + Widget _buildPerDayStatus(BuildContext context) { + final totalCompletedDays = monitorItem.missedTasksPerDay.length; final phases = []; if (studySchedule.includeBaseline) { @@ -120,9 +120,11 @@ class ParticipantDetailsView extends ConsumerWidget { ); } - final String sequence = studySchedule.nameOfSequence; - - for (int i = 0; i < studySchedule.numberOfCycles * 2; i++) { + final numberOfInterventionPhases = studySchedule.numberOfCycles * + (studySchedule.sequence == PhaseSequence.customized + ? studySchedule.sequenceCustom.length + : StudySchedule.numberOfInterventions); + for (int i = 0; i < numberOfInterventionPhases; i++) { final int phaseDuration = studySchedule.phaseDuration; final bool includeBaseline = studySchedule.includeBaseline; final int baselineAdjustmentStart = @@ -140,16 +142,15 @@ class ParticipantDetailsView extends ConsumerWidget { ? baselineAdjustmentEnd : totalCompletedDays; - var sequenceIndex = i; - if (sequenceIndex > 3) sequenceIndex = i % 4; - - final String interventionName = sequence[sequenceIndex] == "A" - ? interventions[0].name! - : interventions[1].name!; + monitorItem.studySubject.study = study; + final intervention = monitorItem.studySubject + .getInterventionsInOrder()[i + (includeBaseline ? 1 : 0)]; + final interventionIdx = study.interventions.indexOf(intervention); phases.add( StudyPhase( - name: interventionName, + name: intervention.name!, + prefix: interventionPrefix(interventionIdx, context), missedTasksPerDay: monitorItem.missedTasksPerDay.sublist(start, end), ), ); @@ -165,9 +166,15 @@ class ParticipantDetailsView extends ConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - phase.name, - style: const TextStyle(fontWeight: FontWeight.bold), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (phase.prefix != null) phase.prefix!, + Text( + phase.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], ), const SizedBox(height: 10), Wrap( @@ -232,20 +239,24 @@ class ParticipantDetailsView extends ConsumerWidget { for (final intervention in interventions) { for (final task in intervention.tasks) { if (missedTaskIds.contains(task.id)) { - sb.writeln('\u{274C} ${intervention.name} - ${task.title}'); + sb.writeln('\u{274C} ${task.title}'); } else if (completedTaskIds.contains(task.id)) { - sb.writeln('\u{2705} ${intervention.name} - ${task.title}'); + sb.writeln('\u{2705} ${task.title}'); } } } for (final observation in observations) { if (missedTaskIds.contains(observation.id)) { - sb.write('\u{274C} ${observation.title}'); + sb.writeln('\u{274C} ${observation.title}'); } else if (completedTaskIds.contains(observation.id)) { - sb.write('\u{2705} ${observation.title}'); + sb.writeln('\u{2705} ${observation.title}'); } } - return sb.toString(); + String str = sb.toString(); + if (str.isNotEmpty) { + str = sb.toString().substring(0, sb.length - 1); + } + return str; } Widget _buildColorLegend() { @@ -354,7 +365,12 @@ class ParticipantDetailsView extends ConsumerWidget { class StudyPhase { final String name; + final Widget? prefix; final List> missedTasksPerDay; - StudyPhase({required this.name, required this.missedTasksPerDay}); + StudyPhase({ + required this.name, + this.prefix, + required this.missedTasksPerDay, + }); } diff --git a/designer_v2/lib/features/monitor/study_monitor_page.dart b/designer_v2/lib/features/monitor/study_monitor_page.dart index 87bb7f042..d3c2b485b 100644 --- a/designer_v2/lib/features/monitor/study_monitor_page.dart +++ b/designer_v2/lib/features/monitor/study_monitor_page.dart @@ -28,7 +28,7 @@ class StudyMonitorScreen extends StudyPageWidget { children: [ _monitorSectionHeader(context, studyMonitorData), const SizedBox(height: 32.0), - if (studyMonitorData.items.isNotEmpty) ...[ + if (studyMonitorData.isNotEmpty) ...[ SelectableText( tr.monitoring_participants_title, style: Theme.of(context).textTheme.headlineSmall, @@ -36,7 +36,7 @@ class StudyMonitorScreen extends StudyPageWidget { Container(width: 32.0), StudyMonitorTable( ref: ref, - studyMonitorItems: studyMonitorData.items, + studyMonitorItems: studyMonitorData, onSelectItem: (item) => _onSelectParticipant(context, ref, item, study), ), @@ -54,24 +54,29 @@ class StudyMonitorScreen extends StudyPageWidget { Widget _monitorSectionHeader( BuildContext context, - StudyMonitorData monitorData, + List monitorData, ) { final theme = Theme.of(context); - final int total = monitorData.items.length; + final int total = monitorData.length; const double minPercentage = 0.01; // Minimum percentage for visibility - double activePercentage = monitorData.activeParticipants / total; - double inactivePercentage = monitorData.inactiveParticipants / total; - double dropoutPercentage = monitorData.dropoutParticipants / total; - double completedPercentage = monitorData.completedParticipants / total; + double activePercentage = monitorData.activeParticipants.length / total; + double inactivePercentage = monitorData.inactiveParticipants.length / total; + double dropoutPercentage = monitorData.dropoutParticipants.length / total; + double completedPercentage = + monitorData.completedParticipants.length / total; // Adjust for minimum percentage visibility - if (monitorData.activeParticipants == 0) activePercentage = minPercentage; - if (monitorData.inactiveParticipants == 0) { + if (monitorData.activeParticipants.isEmpty) { + activePercentage = minPercentage; + } + if (monitorData.inactiveParticipants.isEmpty) { inactivePercentage = minPercentage; } - if (monitorData.dropoutParticipants == 0) dropoutPercentage = minPercentage; - if (monitorData.completedParticipants == 0) { + if (monitorData.dropoutParticipants.isEmpty) { + dropoutPercentage = minPercentage; + } + if (monitorData.completedParticipants.isEmpty) { completedPercentage = minPercentage; } @@ -137,28 +142,28 @@ class StudyMonitorScreen extends StudyPageWidget { _buildLegend( color: activeColor, text: - '${tr.monitoring_active}: ${monitorData.activeParticipants}', + '${tr.monitoring_active}: ${monitorData.activeParticipants.length}', tooltip: tr.monitoring_active_tooltip, ), const SizedBox(width: 10), _buildLegend( color: inactiveColor, text: - '${tr.monitoring_inactive}: ${monitorData.inactiveParticipants}', + '${tr.monitoring_inactive}: ${monitorData.inactiveParticipants.length}', tooltip: tr.monitoring_inactive_tooltip, ), const SizedBox(width: 10), _buildLegend( color: dropoutColor, text: - '${tr.monitoring_dropout}: ${monitorData.dropoutParticipants}', + '${tr.monitoring_dropout}: ${monitorData.dropoutParticipants.length}', tooltip: tr.monitoring_dropout_tooltip, ), const SizedBox(width: 10), _buildLegend( color: completedColor, text: - '${tr.monitoring_completed}: ${monitorData.completedParticipants}', + '${tr.monitoring_completed}: ${monitorData.completedParticipants.length}', tooltip: tr.monitoring_completed_tooltip, ), ], @@ -214,15 +219,12 @@ class StudyMonitorScreen extends StudyPageWidget { StudyMonitorItem item, Study study, ) { - // TODO: refactor to use [RoutingIntent] for sidesheet (so that it can be triggered from controller) showModalSideSheet( context: context, title: tr.participant_details_title, body: ParticipantDetailsView( monitorItem: item, - interventions: study.interventions, - observations: study.observations, - studySchedule: study.schedule, + study: study, ), actionButtons: [ retainSizeInAppBar(