diff --git a/assets/lang/da.json b/assets/lang/da.json index 61fcabba..ad6fb3d2 100644 --- a/assets/lang/da.json +++ b/assets/lang/da.json @@ -8,9 +8,9 @@ "settings": "Indstillinger", "get": "Få", "app_home.nav_bar_item.tasks": "Opgaver", - "app_home.nav_bar_item.about": "Om", - "app_home.nav_bar_item.data": "Data", - "app_home.nav_bar_item.devices": "Enheder", + "app_home.nav_bar_item.statistics": "Statistik", + "app_home.nav_bar_item.connections": "Forbindelser", + "app_home.nav_bar_item.home": "Hjem", "invitation.invitations": "Invitationer", "invitation.subtitle": "Her kan du finde alle de invitationer til studier, du er blevet inviteret til.", "invitation.invited_to_study": "Invitation til studie", @@ -48,11 +48,13 @@ "cards.scoreboard.tasks": "Antal opgaver udført", "cards.scoreboard.tasks-short": "Opgaver", "cards.measures.title": "MÅLINGER", - "cards.survey.title": "SPØRGESKEMAER", + "cards.survey.title": "Besvarede spørgeskemaer", + "cards.task_completion.title": "Opgaver gennemført de sidste 14 dage", + "cards.task_completion.title_on": "Opgaver gennemført den", "cards.audio.title": "LYDOPTAGELSER", "cards.video.title": "VIDEOS", "cards.image.title": "FOTO", - "cards.study_progress.title": "STATUS PÅ STUDIET", + "cards.study_progress.title": "Opgaver", "cards.study_progress.pending": "Igangværende", "cards.study_progress.completed": "Afsluttet", "cards.study_progress.expired": "Udløbet", @@ -70,10 +72,14 @@ "cards.distance.average": "Gennemsnit", "cards.steps.title": "Antal Skridt", "cards.steps.steps": "skridt", + "cards.steps.per_week": "skridt/uge", "cards.heartrate.title": "Hjerterytme", "cards.heartrate.bpm": "SPM", "cards.heartrate.heartrate": "Hjerterytme", "cards.heartrate.range": "Målinger", + "cards.heartrate.average": "Gennemsnit", + "cards.heartrate.day": "Dag", + "cards.heartrate.week": "Uge", "pages.audio_task.play": "Tryk på knappen nedenfor, når du er klar.", "pages.audio_task.recording": "Optager...", "pages.audio_task.recording_completed": "Optagelsen er færdig. Tryk på den grønne knap for at gemme denne optagelse.\n\nHvis du vil gentage optagelsen, tryk på knappen til venstre.", @@ -97,9 +103,10 @@ "pages.profile.device_role": "Engedsrolle", "pages.profile.contact": "Kontakt forsker", "pages.profile.privacy": "Fortrolighedspolitik", + "pages.profile.download_consent": "Download informeret samtykke", "pages.profile.study_website": "Hjemmeside for studiet", "pages.profile.leave_study": "Forlad studie", - "pages.profile.log_out": "Forlad studie & Log ud", + "pages.profile.log_out": "Log ud", "pages.profile.log_out.confirmation": "Du er ved at forlade dette studie og logge af. Operativsystemet vil åbne en browser for at logge dig ud. Er du sikker?", "pages.profile.leave_study.confirmation": "Du er ved at forlade studiet. Du vil ikke længere deltage i dette studie. Er du sikker?", "announcements": "Annonceringer", @@ -136,7 +143,7 @@ "pages.ic.need_accept": "Du skal acceptere denne informerede samtykke for at kunne deltage i dette studie.", "pages.ic.go_to_ic": "Tilbage til samtykke", "pages.devices.title": "Enheder", - "pages.devices.message": "Her kan du se hvilke enheder og online tjenester som anvendes i dette studie.", + "pages.devices.message": "Her kan du forbinde de online tjenester og enheder, som dette studie bruger til at indsamle data.", "pages.devices.phone.title": "Telefon", "pages.devices.devices.title": "Enheder", "pages.devices.services.title": "Ydelser", diff --git a/assets/lang/en.json b/assets/lang/en.json index c7355678..740a6cb6 100644 --- a/assets/lang/en.json +++ b/assets/lang/en.json @@ -9,9 +9,9 @@ "get": "Get", "scan": "Scan", "app_home.nav_bar_item.tasks": "Tasks", - "app_home.nav_bar_item.about": "About", - "app_home.nav_bar_item.data": "Data", - "app_home.nav_bar_item.devices": "Devices", + "app_home.nav_bar_item.statistics": "Statistics", + "app_home.nav_bar_item.connections": "Connections", + "app_home.nav_bar_item.home": "Home", "invitation.invitations": "Invitations", "invitation.subtitle": "Here you can find all the invitations to studies that you have been invited to.", "invitation.invited_to_study": "Study Invitation", @@ -52,11 +52,13 @@ "cards.scoreboard.tasks": "Tasks completed", "cards.scoreboard.tasks-short": "Tasks", "cards.measures.title": "MEASURES", - "cards.survey.title": "SURVEYS", + "cards.survey.title": "Completed Surveys", + "cards.task_completion.title": "Tasks completed in the last 14 days", + "cards.task_completion.title_on": "Tasks completed on", "cards.audio.title": "AUDIOS", "cards.video.title": "VIDEOS", "cards.image.title": "PHOTOS", - "cards.study_progress.title": "STUDY PROGRESS", + "cards.study_progress.title": "Tasks", "cards.study_progress.pending": "Pending", "cards.study_progress.completed": "Completed", "cards.study_progress.expired": "Expired", @@ -74,10 +76,14 @@ "cards.distance.average": "Average", "cards.steps.title": "Number of Steps", "cards.steps.steps": "steps", + "cards.steps.per_week": "steps/week", "cards.heartrate.title": "Heart Rate", "cards.heartrate.bpm": "BPM", "cards.heartrate.heartrate": "Heart rate", "cards.heartrate.range": "Range", + "cards.heartrate.average": "Average", + "cards.heartrate.day": "Day", + "cards.heartrate.week": "Week", "pages.audio_task.play": "Please press the button below when ready.", "pages.audio_task.recording": "Recording...", "pages.audio_task.recording_completed": "Recording completed. Press the green button to save this recording.\n\nIf you want to redo the recording, then press the button on the left.", @@ -111,8 +117,9 @@ "pages.profile.contact": "Contact researcher", "pages.profile.privacy": "Privacy policy", "pages.profile.study_website": "Study website", + "pages.profile.download_consent": "Download Informed Consent", "pages.profile.leave_study": "Leave study", - "pages.profile.log_out": "Leave study & Log out", + "pages.profile.log_out": "Sign Out", "pages.profile.log_out.confirmation": "You are about to leave this study and log out. The operating system will open a browser to log you out. Are you sure?", "pages.profile.leave_study.confirmation": "You are about to leave the study. You will no longer participate in this study. Are you sure?", "announcements": "Announcements", @@ -144,7 +151,7 @@ "pages.ic.need_accept": "You need to accept the informed consent to participate in this study.", "pages.ic.go_to_ic": "Back to informed consent", "pages.devices.title": "Devices", - "pages.devices.message": "Here you can see the devices and online services used in your study.", + "pages.devices.message": "Here you can connect the online services and devices this study uses to collect data.", "pages.devices.phone.title": "Phone", "pages.devices.devices.title": "Devices", "pages.devices.services.title": "Online Services", diff --git a/assets/lang/es.json b/assets/lang/es.json index da6cfe57..ce52848f 100644 --- a/assets/lang/es.json +++ b/assets/lang/es.json @@ -7,9 +7,9 @@ "pages.devices.type.health.instructions.data.heart_rate": "Frecuencia cardíaca", "pages.devices.type.health.instructions.data.exercise": "Sesiones de ejercicio", "app_home.nav_bar_item.tasks": "Tareas", - "app_home.nav_bar_item.about": "Acerca de", - "app_home.nav_bar_item.data": "Datos", - "app_home.nav_bar_item.devices": "Dispositivos", + "app_home.nav_bar_item.statistics": "Estadísticas", + "app_home.nav_bar_item.connections": "Conexiones", + "app_home.nav_bar_item.home": "Inicio", "pages.task_list.title": "MIS TAREAS", "pages.task_list.pending": "Pendientes", "pages.task_list.completed": "Completadas", @@ -29,11 +29,13 @@ "cards.scoreboard.days": "Dias en el estudio", "cards.scoreboard.tasks": "Tareas completadas", "cards.measures.title": "MEDICIONES", - "cards.survey.title": "ENCUESTAS", + "cards.survey.title": "Encuestas completadas", + "cards.task_completion.title": "Tareas completadas en los últimos 14 días", + "cards.task_completion.title_on": "Tareas completadas el", "cards.audio.title": "AUDIOS", "cards.video.title": "VIDEOS", "cards.image.title": "FOTOS", - "cards.study_progress.title": "PROGRESO DE LAS TAREAS", + "cards.study_progress.title": "Tareas", "cards.study_progress.pending": "Pendientes", "cards.study_progress.completed": "Completadas", "cards.study_progress.expired": "Expiradas", @@ -47,6 +49,13 @@ "cards.mobility.places": "lugares", "cards.steps.title": "Numero de pasos", "cards.steps.steps": "pasos", + "cards.steps.per_week": "pasos/semana", + "cards.heartrate.title": "Frecuencia cardíaca", + "cards.heartrate.bpm": "LPM", + "cards.heartrate.range": "Rango", + "cards.heartrate.average": "Promedio", + "cards.heartrate.day": "Día", + "cards.heartrate.week": "Semana", "pages.audio_task.play": "Por favor, presiona el botón de abajo cuando estés listo. ", "pages.audio_task.recording": "Grabando...", "pages.audio_task.recording_completed": "Grabación completada. Presiona el botón verde para guardar la grabación.\n\nSi deseas repetir la grabación, presiona el botón izquierdo.", @@ -65,9 +74,10 @@ "pages.profile.account_id": "Id Usuario", "pages.profile.contact": "Contactar investigador", "pages.profile.privacy": "Política de privacidad", + "pages.profile.download_consent": "Descargar consentimiento informado", "pages.profile.study_website": "Página web del estudio", "pages.profile.leave_study": "Abandonar el estudio", - "pages.profile.log_out": "Cerrar estudio & sesión", + "pages.profile.log_out": "Cerrar sesión", "pages.profile.log_out.confirmation": "Estás a punto de cerrar sesión et abandonar el estudio. ¿Estás seguro?", "pages.profile.leave_study.confirmation": "Estás a punto de abandonar el estudio. Dejarás de participar en el es estudio. ¿Estás seguro?", "announcement": "Anuncio", @@ -85,7 +95,7 @@ "dialog.location.continue": "Continuar", "pages.ic.need_accept": "Necesitas acceptar el consentimiento para participar en el estudio.", "pages.ic.go_to_ic": "Volver al consentimiento", - "pages.devices.message": "Aquí puedess encontrar todos los dispositivos y servicios que se usan en tu estudio.", + "pages.devices.message": "Aquí puedes conectar los servicios en línea y dispositivos que este estudio usa para recopilar datos.", "pages.devices.phone.title": "Teléfono", "pages.devices.devices.title": "Dispositivos", "pages.devices.services.title": "Servicios", diff --git a/integration_test/join_study_flow_test.dart b/integration_test/join_study_flow_test.dart index 4ffe33c2..63a51539 100644 --- a/integration_test/join_study_flow_test.dart +++ b/integration_test/join_study_flow_test.dart @@ -162,7 +162,7 @@ void main() { // runtime and consent has not been given yet. expect(bloc.study.hasStudy, isTrue); expect(bloc.study.isDeployed, isFalse); - expect(await bloc.consent.hasBeenAccepted(bloc.study.study), isFalse); + expect(LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false, isFalse); await tester.pumpWidget(const CarpStudyApp()); @@ -176,7 +176,7 @@ void main() { ); await pumpUntil(tester, () => bloc.isConfigured, reason: 'study configuration to complete'); - expect(await bloc.consent.hasBeenAccepted(bloc.study.study), isTrue); + expect(LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false, isTrue); expect(bloc.study.isDeployed, isTrue); expect(bloc.study.deployment?.studyDeploymentId, status.studyDeploymentId); diff --git a/lib/blocs/app_log.dart b/lib/blocs/app_log.dart deleted file mode 100644 index 267cf81c..00000000 --- a/lib/blocs/app_log.dart +++ /dev/null @@ -1,67 +0,0 @@ -part of carp_study_app; - -/// Tag prefixed to every app-specific log line. Filter device logs to just the -/// study app's own flow with `flutter logs | grep CARP_STUDY_APP` (these lines -/// are intentionally separate from the framework's `[CAMS ...]` logs). -const String appLogTag = 'CARP_STUDY_APP'; - -/// Log an app-specific [message] under the [appLogTag] tag. -/// -/// Use for tracing the app's own flow (e.g. login → study) without dumping the -/// full state snapshot - reach for [logAppState] when the state matters. -void logApp(String message) => debugPrint('[$appLogTag] $message'); - -/// Log [message] followed by a one-line-per-field snapshot of the current app -/// state: bloc state, auth, backend, the active study/participant ids, consent, -/// and what is persisted in [LocalSettings]. -/// -/// Call this at the key transitions in the login → study flow to answer -/// "where am I and what do I have" at that exact point. -void logAppState(String message) => debugPrint('[$appLogTag] $message\n${_appStateSnapshot()}'); - -/// Evaluate [getter], returning a placeholder instead of throwing - state -/// logging must never crash the flow it is meant to observe. -Object? _safe(Object? Function() getter) { - try { - return getter(); - } catch (error) { - return ''; - } -} - -/// A one-line-per-field dump of the current app state, used by [logAppState]. -String _appStateSnapshot() { - final buffer = StringBuffer(); - void line(String key, Object? value) => buffer.writeln(' $key = $value'); - - SmartphoneStudy? study; - try { - study = bloc.study.study; - } catch (_) {} - final settings = LocalSettings(); - - line('bloc.state', _safe(() => bloc.state.name)); - line( - 'isInitialized/isConfiguring/isConfigured', - _safe(() => '${bloc.isInitialized}/${bloc.isConfiguring}/${bloc.isConfigured}'), - ); - line('deploymentMode', _safe(() => AppConfig.deploymentMode.name)); - line('authenticated', _safe(() => bloc.auth.isAuthenticated)); - line('anonymous', _safe(() => bloc.auth.isAnonymous)); - line('user', _safe(() => bloc.auth.username)); - line('backend.study (seeded in CAWS)', _safe(() => CarpService().study?.studyDeploymentId)); - line('study.hasStudy', _safe(() => bloc.study.hasStudy)); - line('study.isDeployed', _safe(() => bloc.study.isDeployed)); - line('study.deploymentId', study?.studyDeploymentId); - line('study.participantId', study?.participantId); - line('study.participantRole', study?.participantRoleName); - line('study.deviceRole', study?.deviceRoleName); - line('consent.isAccepted', _safe(() => bloc.consent.isAccepted)); - line('LocalSettings.user', _safe(() => settings.user?.username)); - line('LocalSettings.study.deploymentId', _safe(() => settings.studyDeploymentId)); - line('LocalSettings.participant.id', _safe(() => settings.participant?.participantId)); - line('LocalSettings.participant.consentAccepted', _safe(() => settings.participant?.hasInformedConsentBeenAccepted)); - line('LocalSettings.isAnonymous', _safe(() => settings.isAnonymous)); - - return buffer.toString().trimRight(); -} diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index 4147cba6..5420e3e0 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -33,11 +33,9 @@ class CarpAppState extends State { errorBuilder: (context, state) => const ErrorPage(), redirect: (context, state) async { final loc = state.matchedLocation; - logAppState('GoRouter.redirect() - evaluating at location: $loc'); // 1) Not authenticated → login page. if (AppConfig.deploymentMode != DeploymentMode.local && !bloc.auth.isAuthenticated) { - logApp('GoRouter.redirect() - [1] not authenticated → ${LoginPage.route}'); return LoginPage.route; } @@ -45,62 +43,53 @@ class CarpAppState extends State { // details page). Anywhere else gets bounced to the list. if (!bloc.study.hasStudy) { if (loc == InvitationListPage.route || loc.startsWith('${InvitationDetailsPage.route}/')) { - logApp('GoRouter.redirect() - [2] no study, already on invitation flow → stay'); return null; } - logApp('GoRouter.redirect() - [2] no study → ${InvitationListPage.route}'); return InvitationListPage.route; } - // 3) Study selected but consent known to be pending → consent page. - // (null means the consent status is still being checked - stay put.) - if (!bloc.isConfiguring && bloc.consent.isAccepted == false) { - return loc == InformedConsentPage.route ? null : InformedConsentPage.route; - } - // 4) Fully onboarded. return null; }, routes: [ ShellRoute( navigatorKey: _shellNavigatorKey, - builder: (BuildContext context, GoRouterState state, Widget child) => - HomePage(model: bloc.appViewModel.homePageViewModel, child: child), + builder: (BuildContext context, GoRouterState state, Widget child) => CarpAppShell( + model: bloc.appViewModel.homePageViewModel, + consentModel: bloc.appViewModel.informedConsentViewModel, + child: child, + ), routes: [ // Home is just a landing slot — the top-level redirect always moves // the user to the right place based on bloc state. - GoRoute(path: homeRoute, parentNavigatorKey: _shellNavigatorKey, redirect: (_, _) => StudyPage.route), + GoRoute(path: homeRoute, parentNavigatorKey: _shellNavigatorKey, redirect: (_, _) => HomePage.route), GoRoute( - path: TaskListPage.route, + path: HomePage.route, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( - child: TaskListPage(model: bloc.appViewModel.taskListPageViewModel), + key: state.pageKey, + child: HomePage(model: bloc.appViewModel.homePageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), ), GoRoute( - path: StudyPage.route, + path: TaskListPage.route, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( - child: StudyPage(model: bloc.appViewModel.studyPageViewModel), + key: state.pageKey, + child: TaskListPage(model: bloc.appViewModel.taskListPageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), - routes: [ - // /study/consent — nested so the parent StudyPage stays mounted - // underneath. parentNavigatorKey escapes the shell so the bottom - // nav doesn't bleed through during consent. - GoRoute( - path: 'consent', - parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => InformedConsentPage(model: bloc.appViewModel.informedConsentViewModel), - ), - ], ), + // HomePage is the new study landing page; keep /study as a redirect + // alias so any legacy navigation lands on the Home tab. + GoRoute(path: StudyPage.route, parentNavigatorKey: _shellNavigatorKey, redirect: (_, _) => HomePage.route), GoRoute( - path: DataVisualizationPage.route, + path: StatisticsPage.route, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( - child: DataVisualizationPage(bloc.appViewModel.dataVisualizationPageViewModel), + key: state.pageKey, + child: StatisticsPage(bloc.appViewModel.statisticsViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), ), @@ -108,20 +97,31 @@ class CarpAppState extends State { path: DeviceListPage.route, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( + key: state.pageKey, child: DeviceListPage(model: bloc.appViewModel.devicesPageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), ), - GoRoute( - path: ProfilePage.route, - parentNavigatorKey: _shellNavigatorKey, - pageBuilder: (context, state) => CustomTransitionPage( - child: ProfilePage(bloc.appViewModel.profilePageViewModel), - transitionsBuilder: bottomNavigationBarAnimation, - ), - ), ], ), + // Profile is not a bottom-nav tab - it slides in full-screen over the + // shell, so it lives on the root navigator. + GoRoute( + path: ProfilePage.route, + parentNavigatorKey: _rootNavigatorKey, + pageBuilder: (context, state) => CustomTransitionPage( + key: state.pageKey, + child: ProfilePage(bloc.appViewModel.profilePageViewModel), + transitionsBuilder: slideInFromRightAnimation, + ), + ), + // Consent escapes the shell (root navigator) so the bottom nav doesn't + // bleed through while the user is completing informed consent. + GoRoute( + path: InformedConsentPage.route, + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => InformedConsentPage(model: bloc.appViewModel.informedConsentViewModel), + ), GoRoute( path: StudyDetailsPage.route, parentNavigatorKey: _rootNavigatorKey, @@ -238,7 +238,10 @@ class CarpAppState extends State { theme: carpTheme.copyWith( extensions: [carpTheme.extension()!.copyWith(primary: studyAppColors?.primary)], ), - darkTheme: carpDarkTheme, + // Light only until the dark palette is finished; also keeps OS dark mode + // from affecting system-styled surfaces (e.g. system dialogs). + themeMode: ThemeMode.light, + // darkTheme: carpDarkTheme, debugShowCheckedModeBanner: true, routerConfig: _router, ); @@ -258,9 +261,29 @@ class _AppLocalizationsDelegate extends RPLocalizationsDelegate { } } -FadeTransition bottomNavigationBarAnimation( +/// Fade between bottom-nav tabs: the incoming page fades and gently scales in. +/// (On a tab switch the outgoing page is replaced, not popped, so only the +/// incoming page animates - both tabs share the same gray background.) +Widget bottomNavigationBarAnimation( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, +) { + final fade = CurveTween(curve: Curves.easeOut).animate(animation); + return FadeTransition( + opacity: fade, + child: ScaleTransition(scale: Tween(begin: 0.98, end: 1.0).animate(fade), child: child), + ); +} + +/// Slide a full-screen page in from the right, as used for the profile page. +Widget slideInFromRightAnimation( BuildContext context, Animation animation, Animation secondaryAnimation, Widget child, -) => FadeTransition(opacity: animation, child: child); +) { + final tween = Tween(begin: const Offset(1.0, 0.0), end: Offset.zero).chain(CurveTween(curve: Curves.easeInOut)); + return SlideTransition(position: animation.drive(tween), child: child); +} diff --git a/lib/blocs/app_bloc.dart b/lib/core/app_bloc.dart similarity index 81% rename from lib/blocs/app_bloc.dart rename to lib/core/app_bloc.dart index 7d8b9c8d..ff4b8568 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/core/app_bloc.dart @@ -71,27 +71,15 @@ class AppBloc extends ChangeNotifier { 'DeploymentMode: ${AppConfig.deploymentMode.name}, ' 'DebugLevel: ${AppConfig.debugLevel.name}', ); - - // The coordinator is the sole router-notifier - forward service changes. - // Once consent is given, the study can be configured and started. - consent.addListener(_onConsentChanged); - } - - void _onConsentChanged() { - logAppState('AppBloc._onConsentChanged() - consent.isAccepted=${consent.isAccepted}'); - notifyListeners(); - if (consent.isAccepted == true) unawaited(tryConfigureStudy()); } /// Run [configureStudy], surfacing a failure to the user instead of /// throwing. Used by the setup flow and retry affordances, where no /// caller can handle the error. Future tryConfigureStudy() async { - logAppState('AppBloc.tryConfigureStudy() START'); try { await configureStudy(); } catch (error) { - logAppState('AppBloc.tryConfigureStudy() FAILED - $error'); final context = scaffoldKey.currentContext; final locale = context != null ? RPLocalizations.of(context) : null; scaffoldKey.currentState?.showSnackBar( @@ -103,7 +91,6 @@ class AppBloc extends ChangeNotifier { /// Initialize this BLOC. Called before being used for anything. Future initialize() async { if (isInitialized) return; - logAppState('AppBloc.initialize() START'); Settings().debugLevel = AppConfig.debugLevel; await Settings().init(); @@ -124,15 +111,6 @@ class AppBloc extends ChangeNotifier { _state = AppState.initialized; notifyListeners(); debug('$runtimeType initialized - deployment mode: ${AppConfig.deploymentMode.name}'); - - logAppState('AppBloc.initialize() DONE'); - - // For a returning user, check consent - via [_onConsentChanged] this - // routes to the consent page or configures and starts the study. - if (study.hasStudy) { - logApp('AppBloc.initialize() - returning user has a persisted study, refreshing consent status'); - unawaited(consent.refreshStatus(study.study)); - } } /// Set the active study in the app based on an [invitation]. @@ -140,11 +118,6 @@ class AppBloc extends ChangeNotifier { /// The study translations are re-loaded by the app, which listens for the /// study change. void setStudyInvitation(ActiveParticipationInvitation invitation) { - logApp( - 'AppBloc.setStudyInvitation() - accepting invitation: ' - 'deploymentId=${invitation.studyDeploymentId}, participantId=${invitation.participation.participantId}', - ); - // create and save the participant info based on this invitation LocalSettings().participant = Participant.fromParticipationInvitation(invitation); @@ -158,11 +131,6 @@ class AppBloc extends ChangeNotifier { notifyListeners(); info('Invitation received - study: ${study.study}'); - logAppState('AppBloc.setStudyInvitation() DONE - study set, refreshing consent status'); - - // Routes to the consent page - or, if this participant has already - // consented (e.g. on another phone), configures and starts the study. - unawaited(consent.refreshStatus(study.study)); } /// Configure the study deployment and start sensing. @@ -179,10 +147,8 @@ class AppBloc extends ChangeNotifier { Future configureStudy() async { // early out if already configuring or configured if (_state == AppState.configuring || isConfigured) { - logApp('AppBloc.configureStudy() - skipped, already ${_state.name}'); return; } - logAppState('AppBloc.configureStudy() START'); final previousState = _state; _state = AppState.configuring; @@ -192,7 +158,6 @@ class AppBloc extends ChangeNotifier { } catch (error) { _state = previousState; warning('$runtimeType - Study configuration failed - $error'); - logAppState('AppBloc.configureStudy() FAILED - study.configure() threw - $error'); rethrow; } @@ -206,7 +171,6 @@ class AppBloc extends ChangeNotifier { _state = AppState.configured; notifyListeners(); - logAppState('AppBloc.configureStudy() DONE - now starting sensing'); await study.start(); } @@ -241,7 +205,6 @@ class AppBloc extends ChangeNotifier { messages.stop(); await _userTaskNotificationSubscription?.cancel(); _userTaskNotificationSubscription = null; - consent.reset(); // stop sensing and remove all deployment info await study.remove(); diff --git a/lib/data/carp_backend.dart b/lib/core/carp_backend.dart similarity index 100% rename from lib/data/carp_backend.dart rename to lib/core/carp_backend.dart diff --git a/lib/services/resource_manager_factory.dart b/lib/core/resource_manager_factory.dart similarity index 100% rename from lib/services/resource_manager_factory.dart rename to lib/core/resource_manager_factory.dart diff --git a/lib/blocs/sensing.dart b/lib/core/sensing.dart similarity index 100% rename from lib/blocs/sensing.dart rename to lib/core/sensing.dart diff --git a/lib/blocs/app_config.dart b/lib/helpers/app_config.dart similarity index 80% rename from lib/blocs/app_config.dart rename to lib/helpers/app_config.dart index ab5bfd94..042a4621 100644 --- a/lib/blocs/app_config.dart +++ b/lib/helpers/app_config.dart @@ -46,4 +46,13 @@ abstract class AppConfig { /// The localization (language) of this app. static RPLocalizations? localization; + + /// Fill the statistics charts with [DemoChartData] while the study has + /// collected nothing of its own. + /// + /// Never on a production deployment - a participant must only ever be shown + /// their own data. + // ponytail: temporary, so the charts can be designed before devices report. + // Delete this and demo_chart_data.dart once they do. + static bool get useDemoChartData => deploymentMode != DeploymentMode.production; } diff --git a/lib/helpers/app_log.dart b/lib/helpers/app_log.dart new file mode 100644 index 00000000..81c52eb6 --- /dev/null +++ b/lib/helpers/app_log.dart @@ -0,0 +1,11 @@ +part of carp_study_app; + +/// Tag prefixed to every app-specific log line. Filter device logs to just the +/// study app's own flow with `flutter logs | grep CARP_STUDY_APP` (these lines +/// are intentionally separate from the framework's `[CAMS ...]` logs). +const String appLogTag = 'CARP_STUDY_APP'; + +/// Log an app-specific [message] under the [appLogTag] tag. +/// +/// Use for tracing the app's own flow, e.g. login → study. +void logApp(String message) => debugPrint('[$appLogTag] $message'); diff --git a/lib/blocs/util.dart b/lib/helpers/util.dart similarity index 76% rename from lib/blocs/util.dart rename to lib/helpers/util.dart index 1713255c..426c6f21 100644 --- a/lib/blocs/util.dart +++ b/lib/helpers/util.dart @@ -1,5 +1,13 @@ part of carp_study_app; +extension MeasurementTime on Measurement { + /// When this measurement was taken, as local time. + /// + /// The bucket a measurement belongs to comes from here, never from + /// `DateTime.now()` - otherwise anything replayed lands in the present. + DateTime get dateTime => DateTime.fromMicrosecondsSinceEpoch(sensorStartTime); +} + extension StringExtension on String { String truncateTo(int maxLength) => (length <= maxLength) ? this : '${substring(0, maxLength)}...'; } diff --git a/lib/main.dart b/lib/main.dart index 335a4d9d..0cadd30a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,7 +13,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -34,6 +33,8 @@ import 'package:open_settings_plus/open_settings_plus.dart'; import 'package:open_settings_plus/core/open_settings_plus.dart'; import 'package:appcheck/appcheck.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shimmer/shimmer.dart'; import 'package:app_version_update/app_version_update.dart'; import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart' as qr; @@ -59,13 +60,15 @@ import 'package:carp_health_package/health_package.dart'; import 'package:carp_movesense_package/carp_movesense_package.dart'; import 'package:carp_themes_package/carp_themes_package.dart'; -part 'blocs/app_bloc.dart'; -part 'blocs/app_config.dart'; -part 'blocs/app_log.dart'; -part 'blocs/util.dart'; -part 'blocs/sensing.dart'; +part 'core/app_bloc.dart'; +part 'core/sensing.dart'; +part 'core/carp_backend.dart'; +part 'core/resource_manager_factory.dart'; + +part 'helpers/app_config.dart'; +part 'helpers/app_log.dart'; +part 'helpers/util.dart'; -part 'services/resource_manager_factory.dart'; part 'services/system_info_service.dart'; part 'services/auth_service.dart'; part 'services/study_service.dart'; @@ -73,23 +76,22 @@ part 'services/message_service.dart'; part 'services/consent_service.dart'; part 'data/local_settings.dart'; -part 'data/carp_backend.dart'; part 'data/localization_loader.dart'; part 'data/local_resource_manager.dart'; part 'data/participant.dart'; part 'data/local_participation_service.dart'; part 'view_models/view_model.dart'; -part 'view_models/home_page_model.dart'; -part 'view_models/login_page_model.dart'; -part 'view_models/tasklist_page_model.dart'; -part 'view_models/study_page_model.dart'; -part 'view_models/profile_page_model.dart'; -part 'view_models/device_view_models.dart'; -part 'view_models/data_visualization_page_model.dart'; -part 'view_models/informed_consent_page_model.dart'; +part 'view_models/home_view_model.dart'; +part 'view_models/login_view_model.dart'; +part 'view_models/task_list_view_model.dart'; +part 'view_models/study_view_model.dart'; +part 'view_models/profile_view_model.dart'; +part 'view_models/device_view_model.dart'; +part 'view_models/statistics_view_model.dart'; +part 'view_models/informed_consent_view_model.dart'; part 'view_models/invitations_view_model.dart'; -part 'view_models/participant_data_page_model.dart'; +part 'view_models/participant_data_view_model.dart'; part 'view_models/cards/activity_data_model.dart'; part 'view_models/cards/mobility_data_model.dart'; part 'view_models/cards/steps_data_model.dart'; @@ -97,16 +99,18 @@ part 'view_models/cards/heart_rate_data_model.dart'; part 'view_models/cards/measurements_data_model.dart'; part 'view_models/cards/task_data_model.dart'; part 'view_models/cards/study_progress_data_model.dart'; +part 'view_models/cards/demo_chart_data.dart'; part 'view_models/user_tasks.dart'; part 'carp_study_app.dart'; part 'ui/pages/informed_consent_page.dart'; +part 'ui/pages/carp_app_shell.dart'; part 'ui/pages/home_page.dart'; part 'ui/pages/home_page.install_health_connect_dialog.dart'; part 'ui/carp_study_style.dart'; part 'ui/colors.dart'; part 'ui/helpers.dart'; -part 'ui/pages/data_visualization_page.dart'; +part 'ui/pages/statistics_page.dart'; part 'ui/pages/study_page.dart'; part 'ui/pages/task_list_page.dart'; part 'ui/pages/profile_page.dart'; @@ -146,14 +150,15 @@ part 'ui/widgets/location_usage_dialog.dart'; part 'ui/cards/activity_card.dart'; part 'ui/cards/anonymous_card.dart'; +part 'ui/cards/connections_status_card.dart'; part 'ui/cards/distance_card.dart'; part 'ui/cards/heart_rate_card.dart'; part 'ui/cards/media_card.dart'; part 'ui/cards/mobility_card.dart'; -part 'ui/cards/scoreboard_card.dart'; part 'ui/cards/steps_card.dart'; part 'ui/cards/study_progress_card.dart'; part 'ui/cards/survey_card.dart'; +part 'ui/cards/task_completion_card.dart'; part 'main.g.dart'; diff --git a/lib/main.g.dart b/lib/main.g.dart index 95750247..eae4abea 100644 --- a/lib/main.g.dart +++ b/lib/main.g.dart @@ -84,12 +84,16 @@ HourlyHeartRate _$HourlyHeartRateFromJson(Map json) => HourlyHe ..hourlyHeartRate = (json['hourlyHeartRate'] as Map).map( (k, e) => MapEntry(int.parse(k), HeartRateMinMaxPrHour.fromJson(e as Map)), ) + ..dailyHeartRate = (json['dailyHeartRate'] as Map).map( + (k, e) => MapEntry(int.parse(k), HeartRateMinMaxPrHour.fromJson(e as Map)), + ) ..lastUpdated = DateTime.parse(json['lastUpdated'] as String) ..maxHeartRate = (json['maxHeartRate'] as num?)?.toDouble() ..minHeartRate = (json['minHeartRate'] as num?)?.toDouble(); Map _$HourlyHeartRateToJson(HourlyHeartRate instance) => { 'hourlyHeartRate': instance.hourlyHeartRate.map((k, e) => MapEntry(k.toString(), e)), + 'dailyHeartRate': instance.dailyHeartRate.map((k, e) => MapEntry(k.toString(), e)), 'lastUpdated': instance.lastUpdated.toIso8601String(), 'maxHeartRate': ?instance.maxHeartRate, 'minHeartRate': ?instance.minHeartRate, diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 49bb86ca..a6f6bf0e 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -39,11 +39,21 @@ class AuthService { /// Get / refresh the list of active invitations for this user from CAWS, /// keeping only invitations assigned to a smartphone (and not, e.g., a /// web browser). + /// + /// CAWS returns the invitations in no particular order, so they are sorted + /// by study name (deployment id as tie-breaker) to keep the list stable + /// across refreshes. Future> getInvitations() async { final all = await _backend.getInvitations(); - return _invitations = all - .where((invitation) => invitation.assignedDevices?.any((device) => device.device is! Smartphone) != true) - .toList(); + _invitations = + all + .where((invitation) => invitation.assignedDevices?.any((device) => device.device is! Smartphone) != true) + .toList() + ..sort((a, b) { + final byName = a.invitation.name.compareTo(b.invitation.name); + return byName != 0 ? byName : a.studyDeploymentId.compareTo(b.studyDeploymentId); + }); + return _invitations; } /// Authenticate using a web view. diff --git a/lib/services/consent_service.dart b/lib/services/consent_service.dart index 007fb60c..77c83f44 100644 --- a/lib/services/consent_service.dart +++ b/lib/services/consent_service.dart @@ -1,68 +1,34 @@ part of carp_study_app; -/// Manages the informed consent flow: fetching the consent document, checking -/// whether consent has been given, and accepting consent. +/// The backend side of informed consent: fetching the consent document of the +/// study, and reading / writing the user's signed consent in CAWS. /// -/// Notifies its listeners when consent is accepted. Listen via the global -/// [bloc], which chains service notifications to the router. -class ConsentService extends ChangeNotifier { +/// Deployment-mode policy and the locally stored flag are not this layer's +/// concern - see [InformedConsentViewModel]. +class ConsentService { ConsentService(this._manager, {CarpBackend? backend}) : _backend = backend ?? CarpBackend(); final InformedConsentManager _manager; final CarpBackend _backend; - bool? _accepted; - /// Get the informed consent document for this study. + /// Get the informed consent document for this study, or null if it has none. Future getDocument({bool refresh = false}) => _manager.getConsentDocument(refresh: refresh); - /// The last known consent status, as cached by [refreshStatus] or [accept]. - /// Null until the status has been checked. Used by the router redirect, - /// which needs a synchronous answer. - bool? get isAccepted => _accepted; - - /// Check [hasBeenAccepted] for [study], cache the answer in [isAccepted], - /// and notify. The coordinator supplies the active study. - Future refreshStatus(SmartphoneStudy? study) async { - logApp('ConsentService.refreshStatus() START - deploymentId=${study?.studyDeploymentId}'); - _accepted = await hasBeenAccepted(study); - logApp('ConsentService.refreshStatus() DONE - isAccepted=$_accepted'); - notifyListeners(); - return _accepted!; - } - - /// Forget the cached consent status, e.g. when leaving a study. - void reset() => _accepted = null; - - /// Has the informed consent been accepted by the user for [study]? + /// Has a signed informed consent been uploaded for [study]? /// - /// Consent is tied to the account, not the device, so the backend is the - /// single source of truth in non-local deployments. Local mode has no - /// backend and falls back to the locally stored flag. - Future hasBeenAccepted(SmartphoneStudy? study) async { - if (AppConfig.deploymentMode == DeploymentMode.local || study == null) { - return LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false; - } + /// False if there is no study, or if the backend cannot be reached - the user + /// is then asked to sign again rather than being let in on a guess. + Future hasSignedConsent(SmartphoneStudy? study) async { + if (study == null) return false; try { final consent = await _backend.getInformedConsentByRole(study.studyDeploymentId, study.participantRoleName); return consent != null; - } catch (e) { - warning('Could not fetch informed consent status from backend: $e'); + } catch (error) { + warning('Could not fetch informed consent status from backend - $error'); return false; } } - /// Mark the informed consent as accepted: persist locally and (when online) - /// upload the signed [result] to CAWS. Pass `null` when the study has no - /// consent document - only the local flag is set. - Future accept([RPTaskResult? result]) async { - info('Informed consent has been accepted by user.'); - var participant = LocalSettings().participant; - participant?.hasInformedConsentBeenAccepted = true; - LocalSettings().participant = participant; - if (result != null && AppConfig.deploymentMode != DeploymentMode.local) { - await _backend.uploadInformedConsent(result); - } - _accepted = true; - notifyListeners(); - } + /// Upload the signed consent [result] to CAWS. + Future upload(RPTaskResult result) => _backend.uploadInformedConsent(result); } diff --git a/lib/services/message_service.dart b/lib/services/message_service.dart index 0ce3fb4e..392458c7 100644 --- a/lib/services/message_service.dart +++ b/lib/services/message_service.dart @@ -3,15 +3,25 @@ part of carp_study_app; /// Holds the list of messages (news, announcements, articles) shown in the /// app and keeps it refreshed, owning the periodic polling timer. class MessageService { - MessageService(this._manager, {Duration pollingInterval = const Duration(minutes: 30)}) - : _pollingInterval = pollingInterval; + MessageService( + this._manager, { + Duration pollingInterval = const Duration(minutes: 30), + NotificationManager? notificationManager, + }) : _pollingInterval = pollingInterval, + _notificationManager = notificationManager; final MessageManager _manager; final Duration _pollingInterval; + final NotificationManager? _notificationManager; final StreamController _streamController = StreamController.broadcast(); List _messages = []; Timer? _pollingTimer; + /// The message ids seen by an earlier [refresh], or null before the first one. + Set? _knownIds; + + NotificationManager get _notifications => _notificationManager ?? SmartPhoneClientManager().notificationManager; + /// The list of currently available messages, newest first. List get messages => _messages; @@ -46,9 +56,28 @@ class MessageService { } catch (error) { warning('Error getting messages - $error'); } + await _notifyAboutNewMessages(); if (!_streamController.isClosed) _streamController.add(_messages.length); } + /// Notify the user about messages that were not in the previous [refresh]. + Future _notifyAboutNewMessages() async { + final known = _knownIds; + _knownIds = _messages.map((message) => message.id).toSet(); + if (known == null) return; + + for (final message in _messages.where((message) => !known.contains(message.id))) { + try { + await _notifications.createNotification( + title: message.title ?? 'New announcement', + body: message.subTitle ?? message.message, + ); + } catch (error) { + warning('Could not create a notification for message ${message.id} - $error'); + } + } + } + void dispose() { stop(); _streamController.close(); diff --git a/lib/services/study_service.dart b/lib/services/study_service.dart index 5fcc2df6..3c40853f 100644 --- a/lib/services/study_service.dart +++ b/lib/services/study_service.dart @@ -69,11 +69,9 @@ class StudyService { /// case it is safe to call this method again (e.g., once back online). Future configure() async { if (study == null) throw StateError('No study set - cannot configure a study deployment.'); - logApp('StudyService.configure() - deploymentId=${study!.studyDeploymentId}, deviceRole=${study!.deviceRoleName}'); final status = await addStudy(study!); - logApp('StudyService.configure() - addStudy returned status=$status, isDeployed=$isDeployed'); if (!isDeployed) throw StateError('Study deployment did not succeed - status: $status.'); } diff --git a/lib/services/system_info_service.dart b/lib/services/system_info_service.dart index 5b8ed606..fd1e6220 100644 --- a/lib/services/system_info_service.dart +++ b/lib/services/system_info_service.dart @@ -41,4 +41,13 @@ class SystemInfoService { ); return result.canUpdate; } + + /// Open this app's store listing (Play Store on Android, App Store on iOS). + Future openAppStore() async { + final info = await PackageInfo.fromPlatform(); + final url = Platform.isAndroid + ? Uri.parse('https://play.google.com/store/apps/details?id=${info.packageName}') + : Uri.parse('https://apps.apple.com/app/1569798025'); + if (await canLaunchUrl(url)) await launchUrl(url); + } } diff --git a/lib/ui/cards/activity_card.dart b/lib/ui/cards/activity_card.dart index 1c48af92..1cd365b8 100644 --- a/lib/ui/cards/activity_card.dart +++ b/lib/ui/cards/activity_card.dart @@ -10,50 +10,31 @@ class ActivityCard extends StatefulWidget { } class ActivityCardState extends State { - num? _walk = 0; - num? _run = 0; - num? _cycle = 0; + final betweenSpace = 2.4; - num maxValue = 0; - int touchedIndex = DateTime.now().weekday; + /// The weekday whose bar is selected, or null for the week as a whole. + int? _selectedDay; - final betweenSpace = 2.4; + /// The three activity types this card charts, bottom of the bar upwards. + static const List _types = [ActivityType.WALKING, ActivityType.RUNNING, ActivityType.ON_BICYCLE]; - List> activitiesList = List.generate( - 7, - (_) => List.generate(4, (index) => index, growable: false), - growable: false, - ); + /// Minutes of [type] on [weekday]. + num _minutesOn(ActivityType type, int weekday) => widget.model.activities[type]?[weekday] ?? 0; - @override - void initState() { - _walk = widget.model.activities[ActivityType.WALKING]![DateTime.now().weekday]; - _run = widget.model.activities[ActivityType.RUNNING]![DateTime.now().weekday]; - _cycle = widget.model.activities[ActivityType.ON_BICYCLE]![DateTime.now().weekday]; + /// Minutes of [type] on the selected day, or across the week when none is. + num _minutes(ActivityType type) => _selectedDay != null + ? _minutesOn(type, _selectedDay!) + : (widget.model.activities[type]?.values.fold(0, (sum, minutes) => sum + minutes) ?? 0); - /// Doing some conversions to make the data readable by the chart - /// The data is organized in a list of lists, where each list represents a day - /// and each element in the list represents the time spent doing a specific - /// activity. - widget.model.activities.forEach((activityType, innerMap) { - int idx; - if (activityType == ActivityType.WALKING) { - idx = 1; - } else if (activityType == ActivityType.RUNNING) { - idx = 2; - } else if (activityType == ActivityType.ON_BICYCLE) { - idx = 3; - } else { - return; - } - innerMap.forEach((weekday, time) { - activitiesList[weekday - 1][0] = weekday; // Assign weekday at index 0 - activitiesList[weekday - 1][idx] = time; - }); - }); + num get _walk => _minutes(ActivityType.WALKING); + num get _run => _minutes(ActivityType.RUNNING); + num get _cycle => _minutes(ActivityType.ON_BICYCLE); - super.initState(); - } + /// The tallest stacked bar, so the axis has somewhere to end. + num get _maxValue => List.generate( + 7, + (index) => _types.fold(0, (sum, type) => sum + _minutesOn(type, index + 1)), + ).fold(0, max); @override Widget build(BuildContext context) { @@ -61,96 +42,100 @@ class ActivityCardState extends State { return StudiesMaterial( backgroundColor: Theme.of(context).extension()!.white!, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - Row( - children: [ - Text( - '${_walk! + _run! + _cycle!}', - style: fs28fw700.copyWith(color: Theme.of(context).extension()!.grey900!), - ), - Padding( - padding: const EdgeInsets.only(left: 4.0), - child: Text( - '${locale.translate('cards.activity.total.min')} ${_getDayName(touchedIndex)}', + // Tapping anywhere else on the card drops the selection. + child: GestureDetector( + onTap: () => setState(() => _selectedDay = null), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Row( + children: [ + Text( + '${_walk + _run + _cycle}', + style: fs28fw700.copyWith(color: Theme.of(context).extension()!.grey900!), + ), + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text( + '${locale.translate('cards.activity.total.min')} ${_selectedDay != null ? _getDayName(_selectedDay!) : ''}', + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), + ), + ), + ], + ), + Row( + children: [ + Text( + "${widget.model.currentMonth} ${widget.model.startOfWeek} - ${int.parse(widget.model.endOfWeek) < int.parse(widget.model.startOfWeek) ? widget.model.nextMonth : widget.model.currentMonth} ${widget.model.endOfWeek}, ${widget.model.currentYear}", style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), + Spacer(), + ], + ), + SizedBox( + height: 160, + width: MediaQuery.of(context).size.width * 0.9, + child: StreamBuilder( + stream: widget.model.activityEvents, + builder: (context, snapshot) { + return barCharts; + }, ), - ], - ), - Row( - children: [ - Text( - "${widget.model.currentMonth} ${widget.model.startOfWeek} - ${int.parse(widget.model.endOfWeek) < int.parse(widget.model.startOfWeek) ? widget.model.nextMonth : widget.model.currentMonth} ${widget.model.endOfWeek}, ${widget.model.currentYear}", - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), - ), - Spacer(), - ], - ), - SizedBox( - height: 160, - width: MediaQuery.of(context).size.width * 0.9, - child: StreamBuilder( - stream: widget.model.activityEvents, - builder: (context, snapshot) { - return barCharts; - }, ), - ), - Column( - children: [ - Row( - children: [ - Expanded( - child: Row( - children: [ - Text('$_walk', style: fs22fw700.copyWith(color: widget.colors[0])), - Padding( - padding: const EdgeInsets.all(4.0), - child: Text( - locale.translate('cards.activity.walking'), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey800), + Column( + children: [ + Row( + children: [ + Expanded( + child: Row( + children: [ + Text('$_walk', style: fs22fw700.copyWith(color: widget.colors[0])), + Padding( + padding: const EdgeInsets.all(4.0), + child: Text( + locale.translate('cards.activity.walking'), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey800), + ), ), - ), - ], + ], + ), ), - ), - Expanded( - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text('$_run', style: fs12fw700.copyWith(color: widget.colors[1])), - ), - Padding( - padding: const EdgeInsets.all(4.0), - child: Text( - locale.translate('cards.activity.running'), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey800), + Expanded( + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text('$_run', style: fs12fw700.copyWith(color: widget.colors[1])), ), - ), - ], + Padding( + padding: const EdgeInsets.all(4.0), + child: Text( + locale.translate('cards.activity.running'), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey800), + ), + ), + ], + ), ), - ), - ], - ), - Row( - children: [ - Text('$_cycle', style: fs22fw700.copyWith(color: widget.colors[2])), - Padding( - padding: const EdgeInsets.only(left: 4.0), - child: Text( - locale.translate('cards.activity.cycling'), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey800), + ], + ), + Row( + children: [ + Text('$_cycle', style: fs22fw700.copyWith(color: widget.colors[2])), + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text( + locale.translate('cards.activity.cycling'), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey800), + ), ), - ), - ], - ), - ], - ), - ], + ], + ), + ], + ), + ], + ), ), ), ); @@ -171,16 +156,28 @@ class ActivityCardState extends State { topTitles: const AxisTitles(), ), barTouchData: BarTouchData( - enabled: false, - touchCallback: (p0, p1) { - setState(() { - touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; - }); + enabled: true, + // The totals above and below the chart already name the selected day. + touchTooltipData: BarTouchTooltipData(getTooltipColor: (_) => Colors.transparent), + touchCallback: (event, response) { + // Only settle on tap-up, so the selection is not dragged around - + // and a tap on empty chart space clears it. + if (event is! FlTapUpEvent) return; + final index = response?.spot?.touchedBarGroupIndex; + setState(() => _selectedDay = index == null ? null : index + 1); }, ), groupsSpace: 4, - barGroups: activitiesList.map((e) => generateGroupData(e[0], e[1], e[2], e[3])).toList(), - maxY: (maxValue) * 1.2, + barGroups: [ + for (int weekday = 1; weekday <= 7; weekday++) + generateGroupData( + weekday, + _minutesOn(ActivityType.WALKING, weekday), + _minutesOn(ActivityType.RUNNING, weekday), + _minutesOn(ActivityType.ON_BICYCLE, weekday), + ), + ], + maxY: _maxValue * 1.2, gridData: FlGridData( show: true, drawVerticalLine: false, @@ -196,13 +193,10 @@ class ActivityCardState extends State { BarChartGroupData generateGroupData(int x, num walking, num running, num cycling) { double roundness = 2; - bool isTouched = touchedIndex == x; - maxValue = max(maxValue, walking + running + cycling); - if (isTouched) { - _walk = walking; - _run = running; - _cycle = cycling; - } + // Nothing selected means the week as a whole - every bar reads the same. + bool isTouched = _selectedDay == null || _selectedDay == x; + // Solid for the selected day, washed out for the rest. + Color shade(Color color) => isTouched ? color : color.withValues(alpha: 0.3); return BarChartGroupData( x: x, @@ -211,21 +205,21 @@ class ActivityCardState extends State { BarChartRodData( fromY: 0, toY: walking + 0, - color: widget.colors[0].withValues(alpha: isTouched ? 0.8 : 1), + color: shade(widget.colors[0]), width: 32, borderRadius: BorderRadius.all(Radius.circular(roundness)), ), BarChartRodData( fromY: walking + betweenSpace, toY: walking + betweenSpace + running, - color: widget.colors[1].withValues(alpha: isTouched ? 0.8 : 1), + color: shade(widget.colors[1]), width: 32, borderRadius: BorderRadius.all(Radius.circular(roundness)), ), BarChartRodData( fromY: walking + betweenSpace + running + betweenSpace, toY: walking + betweenSpace + running + betweenSpace + cycling, - color: widget.colors[2].withValues(alpha: isTouched ? 0.8 : 1), + color: shade(widget.colors[2]), width: 32, borderRadius: BorderRadius.only( topLeft: const Radius.circular(4), diff --git a/lib/ui/cards/connections_status_card.dart b/lib/ui/cards/connections_status_card.dart new file mode 100644 index 00000000..6bc39d72 --- /dev/null +++ b/lib/ui/cards/connections_status_card.dart @@ -0,0 +1,132 @@ +part of carp_study_app; + +/// Home card summarising the connection state of all data sources (hardware +/// devices + online services, excluding the phone itself). +/// +/// The connection data comes from [HomePageViewModel]; this widget only maps it +/// to visuals and owns the expand/collapse toggle. +// ponytail: labels hardcoded EN to match the rest of the static home page; i18n later. +class ConnectionsStatusCard extends StatefulWidget { + final HomePageViewModel model; + const ConnectionsStatusCard({required this.model, super.key}); + + @override + State createState() => _ConnectionsStatusCardState(); +} + +class _ConnectionsStatusCardState extends State { + static const _green = Color(0xFF22C55E); + static const _amber = Color(0xFFF59E0B); + static const _rose = Color(0xFFF43F5E); + + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension()!; + final locale = RPLocalizations.of(context)!; + final model = widget.model; + final total = model.totalSourceCount; + final active = model.activeSourceCount; + + final (accent, icon, title) = switch (model.connectionState) { + HomeConnectionState.all => (_green, Icons.sync, 'Connected & sending data'), + HomeConnectionState.partial => (_amber, Icons.sync_problem, 'Partially connected'), + HomeConnectionState.none => (_rose, Icons.sync_disabled, 'No devices connected'), + }; + + final sources = switch (model.connectionState) { + HomeConnectionState.all => 'All $total sources active', + HomeConnectionState.partial => '$active sources active · ${total - active} inactive', + HomeConnectionState.none => 'No sources active', + }; + + return StudiesMaterial( + backgroundColor: colors.grey50!, + hasBorder: true, + borderColor: accent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(14)), + child: Icon(icon, color: Colors.white), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: fs18fw700.copyWith(color: colors.grey900)), + const SizedBox(height: 4), + Text(sources, style: fs14fw600.copyWith(color: colors.grey600)), + ], + ), + ), + Container( + decoration: BoxDecoration(color: colors.grey100, shape: BoxShape.circle), + padding: const EdgeInsets.all(4), + child: Icon(_expanded ? Icons.keyboard_arrow_up : Icons.chevron_right, color: colors.grey600), + ), + ], + ), + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 200), + alignment: Alignment.topCenter, + child: _expanded ? _details(context, colors, locale) : const SizedBox(width: double.infinity), + ), + ], + ), + ); + } + + Widget _details(BuildContext context, CarpColors colors, RPLocalizations locale) { + final model = widget.model; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final d in model.connectionSources) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: [ + Icon(Icons.circle, size: 10, color: model.isSourceActive(d) ? _green : colors.grey400), + const SizedBox(width: 8), + Expanded( + child: Text(locale.translate(d.typeName), style: fs16fw400.copyWith(color: colors.grey900)), + ), + Text( + model.isSourceActive(d) ? 'ON' : 'OFF', + style: fs14fw600.copyWith(color: model.isSourceActive(d) ? _green : colors.grey500), + ), + ], + ), + ), + Divider(height: 1, color: colors.grey200), + InkWell( + onTap: () => context.go(DeviceListPage.route), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Text('Manage in Connections', style: fs16fw600.copyWith(color: colors.primary)), + const SizedBox(width: 4), + Icon(Icons.chevron_right, size: 20, color: colors.primary), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/ui/cards/heart_rate_card.dart b/lib/ui/cards/heart_rate_card.dart index 0830352c..5302590e 100644 --- a/lib/ui/cards/heart_rate_card.dart +++ b/lib/ui/cards/heart_rate_card.dart @@ -1,273 +1,286 @@ part of carp_study_app; +/// Which aggregation the heart rate chart shows. +enum HeartRateRange { day, week } + class HeartRateCardWidget extends StatefulWidget { final HeartRateCardViewModel model; const HeartRateCardWidget(this.model, {super.key}); - factory HeartRateCardWidget.withSampleData(HeartRateCardViewModel model) => HeartRateCardWidget(model); - @override HeartRateCardWidgetState createState() => HeartRateCardWidgetState(); } -class HeartRateCardWidgetState extends State with SingleTickerProviderStateMixin { - late AnimationController animationController; - late Animation animation; +class HeartRateCardWidgetState extends State { + HeartRateRange _range = HeartRateRange.day; - @override - void initState() { - super.initState(); - animationController = AnimationController( - vsync: this, - duration: const Duration(seconds: 1), - lowerBound: 0.9, - upperBound: 1, - )..repeat(reverse: true); - animation = CurvedAnimation(parent: animationController, curve: Curves.easeOut); - } + /// The selected bar, dimming the rest. Stays put until another bar is tapped + /// or the user taps away from the bars. + int? _touched; - @override - void dispose() { - animationController.dispose(); - super.dispose(); - } + /// The bands drawn as bars: one per hour of today, or one per day this week. + Map get _bands => + _range == HeartRateRange.day ? widget.model.hourlyHeartRate : widget.model.dailyHeartRate; @override Widget build(BuildContext context) { + final colors = Theme.of(context).extension()!; + return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: colors.white!, child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - StreamBuilder( - stream: widget.model.heartRateStream, - builder: (context, AsyncSnapshot snapshot) { - return Column( - children: [ - getDailyRange, - SizedBox(height: 240, child: barCharts), - SizedBox(height: 50, child: currentHeartRateWidget), - ], - ); - }, - ), - ], + padding: const EdgeInsets.all(16), + child: StreamBuilder( + stream: widget.model.heartRateStream, + builder: (context, AsyncSnapshot snapshot) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _rangeSummary(colors)), + _rangePicker(colors), + ], + ), + const SizedBox(height: 16), + SizedBox(height: 200, child: _chart(colors)), + const SizedBox(height: 12), + _averageHeartRate(colors), + ], + ), ), ), ); } - Row get getDailyRange { - RPLocalizations locale = RPLocalizations.of(context)!; + /// The buckets that actually hold a reading - what the bars are drawn from, + /// so a selection index means the same thing here as in the chart. + List> get _measuredBands => + _bands.entries.where((entry) => entry.value.max != null).toList(); - final min = widget.model.dayMinMax.min; - final max = widget.model.dayMinMax.max; + /// The selected bar's band, or the range over everything on screen. + Widget _rangeSummary(CarpColors colors) { + final locale = RPLocalizations.of(context)!; + final measuredBands = _measuredBands; + final selected = _touched != null && _touched! < measuredBands.length ? measuredBands[_touched!] : null; - return Row( - crossAxisAlignment: CrossAxisAlignment.end, + final range = + selected?.value ?? + (_range == HeartRateRange.day ? widget.model.dayMinMax : HeartRateCardViewModel.rangeOf(_bands.values)); + final measured = range.min != null && range.max != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - margin: const EdgeInsets.only(left: 8, right: 4, bottom: 4), - child: Text(min == null || max == null ? '-' : '${(min.toInt())} - ${(max.toInt())}', style: fs28fw700), + Text( + selected != null ? _label(selected.key) : locale.translate('cards.heartrate.range'), + style: fs12fw600.copyWith(color: colors.grey600), ), - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Text( - min == null || max == null ? '' : locale.translate('cards.heartrate.bpm'), - style: fs10fw700.copyWith(fontSize: 12, color: Theme.of(context).extension()!.grey600), - ), + const SizedBox(height: 2), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(measured ? '${range.min!.toInt()} - ${range.max!.toInt()}' : '-', style: fs28fw700), + if (measured) ...[ + const SizedBox(width: 6), + Text(locale.translate('cards.heartrate.bpm'), style: fs12fw600.copyWith(color: colors.grey600)), + ], + ], ), ], ); } - Stack get currentHeartRateWidget { - RPLocalizations locale = RPLocalizations.of(context)!; - - final currentHeartRate = widget.model.currentHeartRate; + /// A segmented day / week switch. + Widget _rangePicker(CarpColors colors) { + final locale = RPLocalizations.of(context)!; - return Stack( - children: [ - Positioned( - bottom: 0, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Container( - margin: const EdgeInsets.only(left: 8, bottom: 8, right: 4), - child: currentHeartRate != null - ? Text(currentHeartRate.toStringAsFixed(0), style: fs28fw700) - : Text('-', style: fs28fw700), - ), - Padding( - padding: const EdgeInsets.only(bottom: 14), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RepaintBoundary( - child: ScaleTransition( - scale: Tween(begin: 1, end: 1).animate(animationController), - child: Icon(Icons.favorite, color: CACHET.HEART_RATE_RED, size: 10), - ), - ), - Text( - locale.translate('cards.heartrate.bpm'), - style: fs10fw700.copyWith(color: Theme.of(context).extension()!.grey600), - ), - ], + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration(color: colors.grey100, borderRadius: BorderRadius.circular(100)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final range in HeartRateRange.values) + GestureDetector( + onTap: () => setState(() => _range = range), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: _range == range ? colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(100), + ), + child: Text( + locale.translate('cards.heartrate.${range.name}'), + style: fs12fw600.copyWith(color: _range == range ? colors.grey900 : colors.grey600), ), ), - ], - ), - ), + ), + ], + ), + ); + } + + /// The average over what is on screen, spelled out rather than left as a + /// bare number. + Widget _averageHeartRate(CarpColors colors) { + final locale = RPLocalizations.of(context)!; + final average = HeartRateCardViewModel.averageOf(_bands.values); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Icons.favorite, color: CACHET.HEART_RATE_RED, size: 16), + const SizedBox(width: 8), + Text(locale.translate('cards.heartrate.average'), style: fs14fw600.copyWith(color: colors.grey600)), + const Spacer(), + Text(average != null ? average.toStringAsFixed(0) : '-', style: fs18fw700.copyWith(color: colors.grey900)), + const SizedBox(width: 4), + Text(locale.translate('cards.heartrate.bpm'), style: fs12fw600.copyWith(color: colors.grey600)), ], ); } - BarChart get barCharts { - RPLocalizations locale = RPLocalizations.of(context)!; + /// The y axis fitted to what is on screen - a fixed 0-200 leaves a resting + /// heart rate as a flat band in the bottom quarter of the chart. + /// + /// Rounded out to whole tens with a little headroom, and always at least + /// 40 BPM tall so a steady day does not get magnified into noise. + (double, double, double) get _axis { + final range = HeartRateCardViewModel.rangeOf(_bands.values); + if (range.min == null || range.max == null) return (0, 200, 50); + + var low = max(0.0, ((range.min! - 10) / 10).floor() * 10.0); + var high = ((range.max! + 10) / 10).ceil() * 10.0; + if (high - low < 40) high = low + 40; + + // Four gridlines, on a whole-ten interval so the labels stay round. + final interval = max(10.0, ((high - low) / 4 / 10).ceil() * 10.0); + return (low, low + interval * 4, interval); + } + + Widget _chart(CarpColors colors) { + final (minY, maxY, interval) = _axis; return BarChart( BarChartData( alignment: BarChartAlignment.spaceEvenly, + minY: minY, + maxY: maxY, + groupsSpace: 4, barTouchData: BarTouchData( enabled: true, - touchTooltipData: BarTouchTooltipData( - fitInsideHorizontally: true, - fitInsideVertically: true, - getTooltipItem: (group, groupIndex, rod, rodIndex) { - return BarTooltipItem( - '', - textAlign: TextAlign.start, - children: [ - TextSpan( - text: locale.translate('cards.heartrate.range').toUpperCase(), - style: TextStyle( - color: Theme.of(context).primaryTextTheme.bodySmall?.color, - fontWeight: FontWeight.bold, - ), - ), - TextSpan( - text: "\n${rod.fromY.toInt()}-${rod.toY.toInt()}", - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 30), - ), - TextSpan( - text: "${locale.translate('cards.heartrate.bpm')}\n", - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).primaryTextTheme.bodySmall?.color, - fontSize: 20, - ), - ), - TextSpan( - text: "$groupIndex-${groupIndex + 1} ", - style: TextStyle( - color: Theme.of(context).primaryTextTheme.bodySmall?.color, - fontWeight: FontWeight.bold, - fontSize: 20, - ), - ), - ], - fs28fw700, - ); - }, - ), + touchTooltipData: _tooltip(colors), + touchCallback: (event, response) { + // Only settle on tap-up, so the selection is not dragged around - + // and a tap on empty chart space clears it. + if (event is! FlTapUpEvent) return; + setState(() => _touched = response?.spot?.touchedBarGroupIndex); + }, ), + barGroups: [ + // Only measured buckets get a bar - a zero-height rod at y=0 would + // sit outside the axis now that it no longer starts there. + for (final (index, band) in _measuredBands.indexed) + BarChartGroupData( + x: band.key, + barRods: [ + BarChartRodData( + fromY: band.value.min, + toY: band.value.max!, + // Dim the other bars while one is held, so the tooltip is + // clearly about this bar. + color: _touched == null || _touched == index + ? CACHET.HEART_RATE_RED + : CACHET.HEART_RATE_RED.withValues(alpha: 0.25), + width: _range == HeartRateRange.day ? 6 : 14, + borderRadius: BorderRadius.circular(8), + ), + ], + ), + ], titlesData: FlTitlesData( - show: true, + topTitles: const AxisTitles(), + leftTitles: const AxisTitles(), bottomTitles: AxisTitles( - sideTitles: SideTitles(showTitles: true, interval: 20, getTitlesWidget: bottomTitles), + sideTitles: SideTitles(showTitles: true, reservedSize: 24, getTitlesWidget: _bottomTitle), ), rightTitles: AxisTitles( - sideTitles: SideTitles(showTitles: true, reservedSize: 48, getTitlesWidget: rightTitles), + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: interval, + getTitlesWidget: _rightTitle, + ), ), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), ), gridData: FlGridData( show: true, - drawVerticalLine: true, - drawHorizontalLine: true, - getDrawingHorizontalLine: (value) => FlLine(color: Colors.grey.withValues(alpha: 0.2), strokeWidth: 1), - checkToShowHorizontalLine: (value) => value % 100 == 0, - getDrawingVerticalLine: (value) => - FlLine(color: Colors.grey.withValues(alpha: 0.2), strokeWidth: 1, dashArray: [3, 2]), - verticalInterval: 1 / 24, - checkToShowVerticalLine: (value) { - if ((value * 24).round() == 6) return true; - if ((value * 24).round() == 12) return true; - if ((value * 24).round() == 18) return true; - return false; - }, + horizontalInterval: interval, + drawVerticalLine: false, + getDrawingHorizontalLine: (_) => FlLine(color: colors.grey300!, strokeWidth: 1), ), - borderData: FlBorderData(show: true, border: Border.all(width: 1, color: Colors.grey.withValues(alpha: 0.2))), - groupsSpace: 4, - barGroups: getHeartRateBars(), - minY: 0, - maxY: 200, + borderData: FlBorderData(show: false), ), ); } - Widget bottomTitles(double value, TitleMeta meta) { - final style = TextStyle(color: Colors.grey.withValues(alpha: 0.6), fontSize: 14, fontWeight: FontWeight.bold); - String text; - if (value == 0) { - text = '00'; - } else if (value == 6) { - text = '06'; - } else if (value == 12) { - text = '12'; - } else if (value == 18) { - text = '18'; - } else { - return Container(); - } + BarTouchTooltipData _tooltip(CarpColors colors) { + final locale = RPLocalizations.of(context)!; - return SideTitleWidget( - meta: meta, - space: 0, - child: Text(text, style: style), + return BarTouchTooltipData( + fitInsideHorizontally: true, + fitInsideVertically: true, + tooltipPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + tooltipMargin: 4, + getTooltipColor: (_) => colors.grey900!, + getTooltipItem: (group, groupIndex, rod, rodIndex) => BarTooltipItem( + '${_label(group.x)}\n', + fs12fw600.copyWith(color: colors.grey300), + textAlign: TextAlign.left, + children: [ + TextSpan( + text: '${rod.fromY.toInt()}-${rod.toY.toInt()} ${locale.translate('cards.heartrate.bpm')}', + style: fs14fw600.copyWith(color: colors.white), + ), + ], + ), ); } - Widget rightTitles(double value, TitleMeta meta) { + /// What one bar covers: an hour of today, or a date this week. + String _label(int x) => + _range == HeartRateRange.day ? '${x.toString().padLeft(2, '0')}:00' : DateFormat('dd/MM').format(_dateOf(x)); + + /// The date of [weekday] in the current week. + DateTime _dateOf(int weekday) { + final today = DateTime.now(); + return today.subtract(Duration(days: today.weekday - weekday)); + } + + Widget _bottomTitle(double value, TitleMeta meta) { + final hour = value.toInt(); + // A label under every bar only fits for a week; a day gets one every 6h. + if (_range == HeartRateRange.day && hour % 6 != 0) return const SizedBox.shrink(); + return SideTitleWidget( meta: meta, space: 6, child: Text( - value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', - style: fs14ls1.copyWith(color: Theme.of(context).extension()!.grey600), - maxLines: 1, + _range == HeartRateRange.day ? hour.toString().padLeft(2, '0') : DateFormat('dd/MM').format(_dateOf(hour)), + style: fs12fw600.copyWith(color: Theme.of(context).extension()!.grey600), ), ); } - List getHeartRateBars() => widget.model.hourlyHeartRate.entries - .map( - (value) => BarChartGroupData( - x: value.key, - barRods: [ - BarChartRodData(fromY: value.value.min, toY: value.value.max ?? 0, color: CACHET.HEART_RATE_RED, width: 6), - ], - ), - ) - .toList(); -} - -class HeartRateOuterStatefulWidget extends StatefulWidget { - final HeartRateCardViewModel model; - const HeartRateOuterStatefulWidget(this.model, {super.key}); - - @override - HeartRateOuterStatefulWidgetState createState() => HeartRateOuterStatefulWidgetState(); -} - -class HeartRateOuterStatefulWidgetState extends State { - @override - Widget build(BuildContext context) { - return HeartRateCardWidget.withSampleData(widget.model); - } + Widget _rightTitle(double value, TitleMeta meta) => SideTitleWidget( + meta: meta, + space: 6, + child: Text( + value.toInt().toString(), + style: fs12fw600.copyWith(color: Theme.of(context).extension()!.grey600), + ), + ); } diff --git a/lib/ui/cards/scoreboard_card.dart b/lib/ui/cards/scoreboard_card.dart deleted file mode 100644 index 974d8897..00000000 --- a/lib/ui/cards/scoreboard_card.dart +++ /dev/null @@ -1,163 +0,0 @@ -part of carp_study_app; - -class ScoreboardCard extends StatefulWidget { - final TaskListPageViewModel model; - const ScoreboardCard(this.model, {super.key}); - @override - ScoreboardCardState createState() => ScoreboardCardState(); -} - -class ScoreboardCardState extends State { - @override - Widget build(BuildContext context) { - RPLocalizations locale = RPLocalizations.of(context)!; - - return SliverPersistentHeader( - pinned: false, - delegate: ScoreboardPersistentHeaderDelegate(model: widget.model, locale: locale, minExtent: 40, maxExtent: 110), - ); - } -} - -/// Make a [SliverPersistentHeaderDelegate] to use in a [SliverPersistentHeader] widget, -/// that can be used in a [CustomScrollView]. -/// This is used in the [StudyPage] to make the header of the page. -/// The delegate should retract from 110px to 40px when scrolling down. -/// The animation should be simple and linear. A stretched header does not do anything. -class ScoreboardPersistentHeaderDelegate extends SliverPersistentHeaderDelegate { - TaskListPageViewModel model; - RPLocalizations locale; - @override - final double minExtent; - @override - final double maxExtent; - - ScoreboardPersistentHeaderDelegate({ - required this.minExtent, - required this.maxExtent, - required this.model, - required this.locale, - }); - - @override - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { - double height = 110; - - double offsetForShrink = 50; - - List childrenDays = [ - Text( - model.daysInStudy.toString(), - style: fs36fw800.copyWith( - fontSize: calculateScrollAwareSizing(shrinkOffset, fs20fw800.fontSize!, fs36fw800.fontSize!), - color: Theme.of(context).extension()!.grey900, - ), - ), - if (shrinkOffset < offsetForShrink) - Text( - locale.translate('cards.scoreboard.days'), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey900), - ), - if (shrinkOffset > offsetForShrink) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - locale.translate('cards.scoreboard.days-short'), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey900), - ), - ), - ]; - - List childrenTasks = [ - Text( - model.taskCompleted.toString(), - style: fs36fw800.copyWith( - fontSize: calculateScrollAwareSizing(shrinkOffset, fs20fw800.fontSize!, fs36fw800.fontSize!), - color: Theme.of(context).extension()!.primary, - ), - ), - if (shrinkOffset < offsetForShrink) - Text( - locale.translate('cards.scoreboard.tasks'), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.primary), - ), - if (shrinkOffset > offsetForShrink) - Expanded( - flex: 0, - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - locale.translate('cards.scoreboard.tasks-short'), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.primary), - ), - ), - ), - ]; - - return Container( - height: height, - decoration: BoxDecoration( - color: Theme.of(context).extension()!.white, - borderRadius: BorderRadius.circular(8), // Rounded corners - ), - child: StreamBuilder( - stream: model.userTaskEvents, - builder: (context, snapshot) { - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: shrinkOffset < offsetForShrink - ? Column(mainAxisAlignment: MainAxisAlignment.center, children: childrenDays) - : Row(mainAxisAlignment: MainAxisAlignment.center, children: childrenDays), - ), - // A vertical divider line with rounded corners that spans from 10% to 90% of the height - Expanded( - flex: 0, - child: Container( - height: calculateScrollAwareSizing(shrinkOffset, minExtent * 0.6, maxExtent * 0.6), - width: 2, - decoration: BoxDecoration( - color: Theme.of(context).dividerColor, - borderRadius: BorderRadius.circular(100), - ), - ), - ), - Expanded( - child: shrinkOffset < offsetForShrink - ? Column(mainAxisAlignment: MainAxisAlignment.center, children: childrenTasks) - : Row(mainAxisAlignment: MainAxisAlignment.center, children: childrenTasks), - ), - ], - ); - }, - ), - ); - } - - // A simple function that returns the font size from the scoreNumberStyle, but increasingly smaller when scrolling down. - // Also used for the size of the divider in the middle - double calculateScrollAwareSizing(double shrinkOffset, double minSize, double maxSize) { - // Calculate the normalized shrinkOffset value in the range [0, 1] - double normalizedShrinkOffset = shrinkOffset / maxExtent; - - // Calculate the font size using linear interpolation - double size = maxSize - normalizedShrinkOffset * (maxSize - minSize); - - // Return the calculated font size - return size; - } - - @override - bool shouldRebuild(ScoreboardPersistentHeaderDelegate oldDelegate) { - return true; - } - - @override - FloatingHeaderSnapConfiguration get snapConfiguration => - FloatingHeaderSnapConfiguration(curve: Curves.linear, duration: const Duration(milliseconds: 100)); - - @override - OverScrollHeaderStretchConfiguration get stretchConfiguration => OverScrollHeaderStretchConfiguration(); -} diff --git a/lib/ui/cards/steps_card.dart b/lib/ui/cards/steps_card.dart index 577a0561..99f691aa 100644 --- a/lib/ui/cards/steps_card.dart +++ b/lib/ui/cards/steps_card.dart @@ -11,16 +11,15 @@ class StepsCardWidget extends StatefulWidget { } class StepsCardWidgetState extends State { - num _step = 0; num maxValue = 0; - int touchedIndex = DateTime.now().weekday; + /// The weekday whose bar is selected, or null for the week as a whole. + int? _selectedDay; - @override - void initState() { - _step = widget.model.steps[DateTime.now().weekday - 1].steps; - super.initState(); - } + /// The headline figure: the selected day, or the whole week when nothing is. + num get _step => _selectedDay != null + ? widget.model.weeklySteps[_selectedDay] ?? 0 + : widget.model.weeklySteps.values.fold(0, (sum, steps) => sum + steps); @override Widget build(BuildContext context) { @@ -28,45 +27,60 @@ class StepsCardWidgetState extends State { return StudiesMaterial( backgroundColor: Theme.of(context).extension()!.white!, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - Row( - children: [ - Text( - _step > 0 ? '$_step' : '0', - style: fs28fw700.copyWith(color: Theme.of(context).extension()!.grey900!), - ), - Padding( - padding: const EdgeInsets.only(left: 4.0), - child: Text( - '${locale.translate('cards.steps.steps')} ${_getDayName(touchedIndex)}', + // Tapping anywhere else on the card drops the selection. + child: GestureDetector( + onTap: () => setState(() => _selectedDay = null), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + '$_step', + maxLines: 1, + style: fs28fw700.copyWith(color: Theme.of(context).extension()!.grey900!), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text( + _selectedDay != null + ? '${locale.translate('cards.steps.steps')} ${_getDayName(_selectedDay!)}' + : locale.translate('cards.steps.per_week'), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), + ), + ), + ], + ), + Row( + children: [ + Text( + "${widget.model.currentMonth} ${widget.model.startOfWeek} - ${int.parse(widget.model.endOfWeek) < int.parse(widget.model.startOfWeek) ? widget.model.nextMonth : widget.model.currentMonth} ${widget.model.endOfWeek}, ${widget.model.currentYear}", style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), + Spacer(), + ], + ), + SizedBox( + height: 160, + width: MediaQuery.of(context).size.width * 0.9, + child: StreamBuilder( + stream: widget.model.pedometerEvents, + builder: (context, snapshot) { + return barCharts; + }, ), - ], - ), - Row( - children: [ - Text( - "${widget.model.currentMonth} ${widget.model.startOfWeek} - ${int.parse(widget.model.endOfWeek) < int.parse(widget.model.startOfWeek) ? widget.model.nextMonth : widget.model.currentMonth} ${widget.model.endOfWeek}, ${widget.model.currentYear}", - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), - ), - Spacer(), - ], - ), - SizedBox( - height: 160, - width: MediaQuery.of(context).size.width * 0.9, - child: StreamBuilder( - stream: widget.model.pedometerEvents, - builder: (context, snapshot) { - return barCharts; - }, ), - ), - ], + ], + ), ), ), ); @@ -82,16 +96,21 @@ class StepsCardWidgetState extends State { ), leftTitles: const AxisTitles(), rightTitles: AxisTitles( - sideTitles: SideTitles(showTitles: true, getTitlesWidget: rightTitles, reservedSize: 48), + // Wide enough for a five-digit step count without wrapping. + sideTitles: SideTitles(showTitles: true, getTitlesWidget: rightTitles, reservedSize: 60), ), topTitles: const AxisTitles(), ), barTouchData: BarTouchData( - enabled: false, - touchCallback: (p0, p1) { - setState(() { - touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; - }); + enabled: true, + // The count above the chart already names the selected day. + touchTooltipData: BarTouchTooltipData(getTooltipColor: (_) => Colors.transparent), + touchCallback: (event, response) { + // Only settle on tap-up, so the selection is not dragged around - + // and a tap on empty chart space clears it. + if (event is! FlTapUpEvent) return; + final index = response?.spot?.touchedBarGroupIndex; + setState(() => _selectedDay = index == null ? null : index + 1); }, ), groupsSpace: 4, @@ -115,18 +134,17 @@ class StepsCardWidgetState extends State { } BarChartGroupData generateGroupData(int x, int step) { - bool isTouched = touchedIndex == x; + // Nothing selected means the week as a whole - every bar reads the same. + bool isTouched = _selectedDay == null || _selectedDay == x; maxValue = max(maxValue, step); - if (isTouched) { - _step = step; - } return BarChartGroupData( x: x, barRods: [ BarChartRodData( toY: step.toDouble(), - color: widget.colors[1].withValues(alpha: isTouched ? 0.8 : 1), + // Solid for the selected day, washed out for the rest. + color: isTouched ? widget.colors[1] : widget.colors[1].withValues(alpha: 0.3), width: 32, borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(4)), ), diff --git a/lib/ui/cards/study_progress_card.dart b/lib/ui/cards/study_progress_card.dart index b1cc044e..b4c9b448 100644 --- a/lib/ui/cards/study_progress_card.dart +++ b/lib/ui/cards/study_progress_card.dart @@ -1,166 +1,139 @@ part of carp_study_app; +/// How the user's tasks are split across completed, pending, and expired - as +/// counts alongside a donut of the same three shares. +/// +/// Tapping a slice highlights it and bolds the count it belongs to. class StudyProgressCardWidget extends StatefulWidget { final StudyProgressCardViewModel model; - final List colors; + const StudyProgressCardWidget( this.model, { super.key, - this.colors = const [CACHET.BLUE_1, CACHET.RED_1, CACHET.GREY_6], + this.colors = const [CACHET.BLUE_1, CACHET.RED_1, CACHET.ORANGE], }); @override - StudyProgressCardWidgetState createState() => StudyProgressCardWidgetState(); + State createState() => _StudyProgressCardWidgetState(); } -class StudyProgressCardWidgetState extends State { +class _StudyProgressCardWidgetState extends State { + /// The selected state, as an index into [StudyProgressCardViewModel.progress], + /// or null when the card shows all three equally. + int? _selected; + @override Widget build(BuildContext context) { - RPLocalizations locale = RPLocalizations.of(context)!; - - widget.model.updateProgress(); return StudiesMaterial( backgroundColor: Theme.of(context).extension()!.white!, - child: Padding( - padding: const EdgeInsets.all(8.0), + // Tapping anywhere else on the card drops the selection. + child: GestureDetector( + onTap: () => setState(() => _selected = null), child: StreamBuilder( stream: widget.model.userTaskEvents, builder: (context, AsyncSnapshot snapshot) { - return Column( - children: [ - Container( - padding: const EdgeInsets.only(left: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [Text(locale.translate('cards.study_progress.title'), style: fs16fw400ls1)], - ), - ), - SizedBox( - height: 130, - child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: List.generate( - widget.model.progress.length, - (index) => Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Row( - children: [ - Text( - widget.model.progress[index].value.toString(), - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: widget.colors[index], - ), - ), - const SizedBox(width: 4), - Text( - locale.translate(widget.model.progress[index].state), - style: const TextStyle(fontSize: 16), - ), - ], - ), - ), - ), - ), - ), - // Circular Progress Representation - Padding( - padding: const EdgeInsets.only(bottom: 18, right: 24.0), - child: SizedBox( - width: 104, - height: 104, - child: CustomPaint( - painter: TaskProgressPainter( - values: widget.model.progress.map((p) => p.value).toList(), - colors: widget.colors, - faintColors: widget.colors.map((c) => c.withValues(alpha: 0.2)).toList(), - ), - ), - ), - ), - ], - ); - }, + widget.model.updateProgress(); + final progress = widget.model.progress; + + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [for (final (index, state) in progress.indexed) _count(context, index, state)], + ), ), - ), - ], + SizedBox(width: 110, height: 110, child: _donut(progress)), + ], + ), ); }, ), ), ); } -} - -class TaskProgressPainter extends CustomPainter { - final List values; - final List colors; - final List faintColors; - final double pi = 3.141592; - - TaskProgressPainter({required this.values, required this.colors, required this.faintColors}); - - @override - void paint(Canvas canvas, Size size) { - final double centerX = size.width / 2; - final double centerY = size.height / 2; - final Offset center = Offset(centerX, centerY); - final double maxRadius = size.width / 2; - final double ringWidth = maxRadius / 3; - - int totalTasks = values.reduce((a, b) => a + b); - if (totalTasks == 0) return; - - List percentages = values.map((v) => v / totalTasks).toList(); - - Paint blackCircle = Paint() - ..color = Colors.black - ..style = PaintingStyle.fill; - // this -2 is to match the ring width of the other rings - canvas.drawCircle(center, maxRadius + ringWidth - 2, blackCircle); + Widget _count(BuildContext context, int index, StudyProgress state) { + final colors = Theme.of(context).extension()!; + final locale = RPLocalizations.of(context)!; + final isSelected = _selected == index; - for (int i = 0; i < 3; i++) { - // i * 2 is the space between the rings - double radius = maxRadius - (i * ringWidth) - i * 2; - double sweepAngle = 2 * pi * percentages[i]; - Paint paintFill = Paint() - ..color = colors[i] - ..style = PaintingStyle.stroke - ..strokeWidth = ringWidth - ..strokeCap = StrokeCap.round; - - Paint paintBackground = Paint() - ..color = faintColors[i] - ..style = PaintingStyle.stroke - ..strokeWidth = ringWidth - ..strokeCap = StrokeCap.round; + return GestureDetector( + onTap: () => setState(() => _selected = isSelected ? null : index), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + // Pad to two digits so the labels line up in a column. + Text( + '${state.value}'.padLeft(2, '0'), + style: fs24fw700.copyWith(color: _shade(widget.colors[index], index)), + ), + const SizedBox(width: 12), + Text( + locale.translate('cards.study_progress.${state.state}'), + style: (isSelected ? fs16fw700 : fs16fw400).copyWith(color: colors.grey900), + ), + ], + ), + ), + ); + } - // draw the arc as a percentage of the full circle - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - pi / 2, - sweepAngle > 0 ? sweepAngle : pi * (percentages[i] + 0.01), - false, - paintFill, + /// [color] at full strength when nothing is selected or this is it, washed + /// out otherwise. + Color _shade(Color color, int index) => + _selected == null || _selected == index ? color : color.withValues(alpha: 0.3); + + Widget _donut(List progress) { + final total = progress.fold(0, (sum, state) => sum + state.value); + // An empty study would render nothing at all - show the ring as untouched. + if (total == 0) { + return PieChart( + PieChartData( + centerSpaceRadius: 28, + sections: [ + PieChartSectionData(color: CACHET.GREY_6.withValues(alpha: 0.2), value: 1, showTitle: false, radius: 22), + ], + ), ); - - // draw a full circle as a background with a faint color - canvas.drawArc(Rect.fromCircle(center: center, radius: radius), 0, 2 * pi, false, paintBackground); } - } - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) { - return true; + // Zero-value states get no slice, so the slice order is not the state + // order - keep the state index on each slice to map a tap back. + final slices = [ + for (final (index, state) in progress.indexed) + if (state.value > 0) (index, state), + ]; + + return PieChart( + PieChartData( + centerSpaceRadius: 28, + sectionsSpace: 2, + startDegreeOffset: 270, + pieTouchData: PieTouchData( + touchCallback: (event, response) { + if (event is! FlTapUpEvent) return; + final slice = response?.touchedSection?.touchedSectionIndex; + final selected = slice == null || slice < 0 ? null : slices[slice].$1; + setState(() => _selected = selected == _selected ? null : selected); + }, + ), + sections: [ + for (final (index, state) in slices) + PieChartSectionData( + color: _shade(widget.colors[index], index), + value: state.value.toDouble(), + showTitle: false, + // The selected slice also stands a little proud of the others. + radius: _selected == index ? 26 : 22, + ), + ], + ), + ); } } diff --git a/lib/ui/cards/survey_card.dart b/lib/ui/cards/survey_card.dart index f2f4314a..c52596b1 100644 --- a/lib/ui/cards/survey_card.dart +++ b/lib/ui/cards/survey_card.dart @@ -1,98 +1,140 @@ part of carp_study_app; +/// How many surveys of each type the user has completed, as a donut with the +/// total in the middle. +/// +/// Tapping a slice highlights it, bolds its legend entry, and swaps the middle +/// number for that type's count. class SurveyCard extends StatefulWidget { final TaskCardViewModel model; final List colors; - const SurveyCard(this.model, {super.key, this.colors = CACHET.COLOR_LIST}); + /// Show the card's own "SURVEYS" header. Off when a page section title + /// already labels the card (e.g. the home page). + final bool showTitle; + + const SurveyCard(this.model, {super.key, this.colors = CACHET.COLOR_LIST, this.showTitle = true}); @override State createState() => _SurveyCardState(); } class _SurveyCardState extends State { - int get totalSurveys => widget.model.tasksTable.entries.length; + /// The selected survey type, or null for every type at once. + int? _selected; + + List> get _surveys => widget.model.tasksTable.entries.toList(); + + /// The number in the middle: the selected type, or every completed survey. + int get _centreCount => _selected != null ? _surveys[_selected!].value : widget.model.tasksDone; + + /// [color] at full strength when nothing is selected or this is it, washed + /// out otherwise. + Color _shade(Color color, int index) => + _selected == null || _selected == index ? color : color.withValues(alpha: 0.3); @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; - if (widget.model.tasksTable.isEmpty) { - return const SizedBox(); - } + if (_surveys.isEmpty) return const SizedBox(); return StudiesMaterial( backgroundColor: Theme.of(context).extension()!.white!, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 10.0), - child: Text(locale.translate('cards.survey.title').toUpperCase(), style: fs16fw400ls1), - ), - SizedBox( - height: 160, - width: MediaQuery.of(context).size.width * 0.9, + // Tapping anywhere else on the card drops the selection. + child: GestureDetector( + onTap: () => setState(() => _selected = null), + // Laid out to match [StudyProgressCardWidget] - same padding, same + // donut, same type sizes - so the two cards read as a pair. + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.showTitle) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(locale.translate('cards.survey.title').toUpperCase(), style: fs16fw400ls1), + ), + Row( + children: [ + Expanded(child: _legend(locale)), + SizedBox(width: 110, height: 110, child: _donut()), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _legend(RPLocalizations locale) { + final colors = Theme.of(context).extension()!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (final (index, survey) in _surveys.indexed) + GestureDetector( + onTap: () => setState(() => _selected = _selected == index ? null : index), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ - // List of text with the number of surveys done for each survey - Expanded( - flex: 2, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: widget.model.tasksTable.entries.map((entry) { - Widget dot = Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: widget.colors[widget.model.tasksTable.keys.toList().indexOf(entry.key)], - shape: BoxShape.circle, - ), - ); - Widget text = Text( - '${entry.value} ${locale.translate(entry.key).truncateTo(12)}', - style: fs12fw400, - ); - return Row(children: [dot, const SizedBox(width: 8), text]); - }).toList(), - ), - ), + // Pad to two digits so the labels line up in a column. + Text( + '${survey.value}'.padLeft(2, '0'), + style: fs24fw700.copyWith(color: _shade(widget.colors[index], index)), ), - // The pie chart - Expanded( - flex: 3, - child: Stack( - alignment: Alignment.center, - children: [ - PieChart(PieChartData(sections: pieChartSections, startDegreeOffset: 270)), - Text( - '$totalSurveys', - style: fs24fw700.copyWith(color: Theme.of(context).extension()!.grey800), - ), - ], + const SizedBox(width: 12), + Flexible( + child: Text( + locale.translate(survey.key), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: (_selected == index ? fs16fw700 : fs16fw400).copyWith(color: colors.grey900), ), ), ], ), ), - ], - ), - ), + ), + ], ); } - List get pieChartSections { - return widget.model.tasksTable.entries.map((entry) { - return PieChartSectionData( - // Color should be the next color in the list - color: widget.colors[widget.model.tasksTable.keys.toList().indexOf(entry.key)], - value: entry.value.toDouble(), - title: '${entry.value}', - showTitle: false, - ); - }).toList(); + Widget _donut() { + return Stack( + alignment: Alignment.center, + children: [ + PieChart( + PieChartData( + centerSpaceRadius: 28, + sectionsSpace: 2, + startDegreeOffset: 270, + pieTouchData: PieTouchData( + touchCallback: (event, response) { + if (event is! FlTapUpEvent) return; + final slice = response?.touchedSection?.touchedSectionIndex; + final selected = slice == null || slice < 0 ? null : slice; + setState(() => _selected = selected == _selected ? null : selected); + }, + ), + sections: [ + for (final (index, survey) in _surveys.indexed) + PieChartSectionData( + color: _shade(widget.colors[index], index), + value: survey.value.toDouble(), + showTitle: false, + // The selected slice also stands a little proud of the others. + radius: _selected == index ? 26 : 22, + ), + ], + ), + ), + Text('$_centreCount', style: fs24fw700.copyWith(color: Theme.of(context).extension()!.grey900)), + ], + ); } } diff --git a/lib/ui/cards/task_completion_card.dart b/lib/ui/cards/task_completion_card.dart new file mode 100644 index 00000000..17cd872f --- /dev/null +++ b/lib/ui/cards/task_completion_card.dart @@ -0,0 +1,163 @@ +part of carp_study_app; + +/// How many tasks the user completed on each of the last two weeks' days, as a +/// bar per day. Tapping a bar selects it and reports that day above the chart. +class TaskCompletionCard extends StatefulWidget { + final StudyProgressCardViewModel model; + const TaskCompletionCard(this.model, {super.key}); + + @override + State createState() => _TaskCompletionCardState(); +} + +class _TaskCompletionCardState extends State { + /// The bar the user tapped, or null for the whole fortnight. + int? _selected; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension()!; + final locale = RPLocalizations.of(context)!; + final completions = widget.model.recentCompletions; + final selected = _selected; + + return StudiesMaterial( + backgroundColor: colors.white!, + // Tapping anywhere else on the card drops the selection. + child: GestureDetector( + onTap: () => setState(() => _selected = null), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + selected == null + ? locale.translate('cards.task_completion.title') + : '${locale.translate('cards.task_completion.title_on')} ' + '${DateFormat('dd/MM').format(_dateOf(selected))}', + style: fs16fw700, + ), + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colors.primary!.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.event_note_outlined, color: colors.primary, size: 18), + ), + ], + ), + const SizedBox(height: 8), + _headline(colors, locale, completions), + const SizedBox(height: 8), + SizedBox(height: 140, child: _chart(context, completions)), + ], + ), + ), + ), + ); + } + + /// The count the title refers to: the selected day, or the fortnight total. + Widget _headline(CarpColors colors, RPLocalizations locale, List completions) { + final count = _selected != null ? completions[_selected!] : completions.fold(0, (sum, day) => sum + day); + return Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text('$count', style: fs28fw700.copyWith(color: colors.grey900)), + const SizedBox(width: 6), + Text( + locale.translate('pages.task_list.title').toLowerCase(), + style: fs14fw600.copyWith(color: colors.grey600), + ), + ], + ); + } + + /// The day the bar at [index] covers, counting back from today. + DateTime _dateOf(int index) => + DateTime.now().subtract(Duration(days: StudyProgressCardViewModel.completionHistoryDays - 1 - index)); + + Widget _chart(BuildContext context, List completions) { + final colors = Theme.of(context).extension()!; + final busiest = completions.fold(0, max); + // Whole-number gridlines - a fractional interval renders as repeated + // labels ("1 1 0 0") once the busiest day is only a task or two. + final step = busiest <= 4 ? 1 : (busiest / 4).ceil(); + + return BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: (step * 4).toDouble(), + barTouchData: BarTouchData( + enabled: true, + // The header above the chart reports the selection - a tooltip on top + // of it would say the same thing twice. + touchTooltipData: BarTouchTooltipData(getTooltipColor: (_) => Colors.transparent), + touchCallback: (event, response) { + if (event is! FlTapUpEvent) return; + // Tapping the selected bar - or the empty space around them - clears + // the selection and puts the fortnight total back. + final index = response?.spot?.touchedBarGroupIndex; + setState(() => _selected = index == _selected ? null : index); + }, + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: step.toDouble(), + getDrawingHorizontalLine: (_) => FlLine(color: colors.grey300!, strokeWidth: 1), + ), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + topTitles: const AxisTitles(), + rightTitles: const AxisTitles(), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 32, + interval: step.toDouble(), + getTitlesWidget: (value, meta) => _axisLabel(context, meta, '${value.toInt()}'), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 20, + getTitlesWidget: (value, meta) => _axisLabel(context, meta, '${value.toInt() + 1}'), + ), + ), + ), + barGroups: [ + for (final (index, completed) in completions.indexed) + BarChartGroupData( + x: index, + barRods: [ + BarChartRodData( + toY: completed.toDouble(), + // Nothing selected means the fortnight as a whole - every bar + // reads the same. Otherwise only the selected one is solid. + color: _selected == null || index == _selected + ? colors.primary + : colors.primary!.withValues(alpha: 0.25), + width: 12, + borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), + ), + ], + ), + ], + ), + ); + } + + Widget _axisLabel(BuildContext context, TitleMeta meta, String text) => SideTitleWidget( + meta: meta, + child: Text(text, style: fs12fw400.copyWith(color: Theme.of(context).extension()!.grey600)), + ); +} diff --git a/lib/ui/pages/carp_app_shell.dart b/lib/ui/pages/carp_app_shell.dart new file mode 100644 index 00000000..0fb72471 --- /dev/null +++ b/lib/ui/pages/carp_app_shell.dart @@ -0,0 +1,154 @@ +part of carp_study_app; + +/// The app shell shown once onboarding is done: a [Scaffold] hosting the +/// bottom navigation bar and the current tab (via the [ShellRoute] child). +/// +/// This is also the informed consent gate. The router only mounts the shell +/// once the user is signed in and has a study, so mounting it is exactly the +/// precondition consent still has to be checked against - no matter whether we +/// arrived from a cold start or from accepting an invitation. +class CarpAppShell extends StatefulWidget { + final HomePageViewModel model; + final InformedConsentViewModel consentModel; + final Widget child; + const CarpAppShell({required this.model, required this.consentModel, required this.child, super.key}); + + @override + CarpAppShellState createState() => CarpAppShellState(); +} + +class CarpAppShellState extends State { + @override + void initState() { + super.initState(); + widget.model.addListener(_onModelChanged); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _isInformedConsentAccepted(); + }); + } + + /// Show the informed consent if the study still needs it, then let the study + /// configure itself. Backing out of consent leaves the study, which sends the + /// router back to the invitation list. + Future _isInformedConsentAccepted() async { + try { + final status = await widget.consentModel.hasBeenAccepted(); + if (!mounted) return; + + if (status == ConsentStatus.needsSigning) { + final signed = await context.push(InformedConsentPage.route); + if (signed != true) { + // Declining leaves the study, so there is nothing left to configure. + await widget.consentModel.reject(); + return; + } + } + // Deploying the study and starting sensing + unawaited(bloc.tryConfigureStudy()); + } catch (error) { + warning('$runtimeType - could not resolve informed consent - $error'); + } + } + + @override + void dispose() { + widget.model.removeListener(_onModelChanged); + super.dispose(); + } + + void _onModelChanged() { + if (widget.model.shouldPromptHealthConnectInstall && mounted) { + widget.model.healthConnectPromptShown(); + showDialog( + context: context, + barrierDismissible: true, + builder: (context) => InstallHealthConnectDialog(context), + ); + } + } + + @override + Widget build(BuildContext context) { + RPLocalizations locale = RPLocalizations.of(context)!; + + return Scaffold( + backgroundColor: Theme.of(context).extension()!.backgroundGray, + body: SafeArea(child: widget.child), + // The tabs have nothing to show until the study is loaded - the home page + // is still a skeleton at that point - so keep them inert and dimmed. + bottomNavigationBar: ListenableBuilder( + listenable: widget.model, + builder: (context, navigationBar) => IgnorePointer( + ignoring: !widget.model.isLoaded, + child: Opacity(opacity: widget.model.isLoaded ? 1 : 0.4, child: navigationBar), + ), + child: BottomNavigationBar( + backgroundColor: Theme.of(context).extension()!.white, + type: BottomNavigationBarType.fixed, + selectedItemColor: Theme.of(context).extension()!.primary, + items: [ + BottomNavigationBarItem( + icon: const Icon(Icons.home), + label: locale.translate('app_home.nav_bar_item.home'), + activeIcon: const Icon(Icons.home), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.playlist_add_check), + label: locale.translate('app_home.nav_bar_item.tasks'), + activeIcon: const Icon(Icons.playlist_add_check), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.devices_other), + label: locale.translate('app_home.nav_bar_item.connections'), + activeIcon: const Icon(Icons.devices_other), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.leaderboard), + label: locale.translate('app_home.nav_bar_item.statistics'), + activeIcon: const Icon(Icons.leaderboard), + ), + ], + currentIndex: _calculateSelectedIndex(context), + onTap: (int idx) => _onItemTapped(idx, context), + ), + ), + ); + } + + static int _calculateSelectedIndex(BuildContext context) { + final String location = GoRouterState.of(context).matchedLocation; + if (location.startsWith(HomePage.route)) { + return 0; + } + if (location.startsWith(TaskListPage.route)) { + return 1; + } + if (location.startsWith(DeviceListPage.route)) { + return 2; + } + if (location.startsWith(StatisticsPage.route)) { + return 3; + } + return -1; + } + + void _onItemTapped(int index, BuildContext context) { + switch (index) { + case 0: + context.go(HomePage.route); + break; + case 1: + context.go(TaskListPage.route); + break; + case 2: + context.go(DeviceListPage.route); + break; + case 3: + context.go(StatisticsPage.route); + break; + case -1: + context.go(CarpAppState.homeRoute); + break; + } + } +} diff --git a/lib/ui/pages/data_visualization_page.dart b/lib/ui/pages/data_visualization_page.dart deleted file mode 100644 index 6bd7a5a4..00000000 --- a/lib/ui/pages/data_visualization_page.dart +++ /dev/null @@ -1,123 +0,0 @@ -part of carp_study_app; - -// todo change text for survey progress -class DataVisualizationPage extends StatefulWidget { - static const String route = '/data'; - final DataVisualizationPageViewModel model; - const DataVisualizationPage(this.model, {super.key}); - - @override - State createState() => _DataVisualizationPageState(); -} - -class _DataVisualizationPageState extends State { - @override - Widget build(BuildContext context) { - RPLocalizations locale = RPLocalizations.of(context)!; - return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, - body: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), - child: const CarpAppBar(hasProfileIcon: true), - ), - Container( - color: Colors.transparent, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: Align( - alignment: Alignment.centerLeft, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - locale.translate('pages.data_viz.title'), - style: fs24fw700.copyWith( - color: Theme.of(context).extension()!.grey900, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ), - Expanded( - flex: 4, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 24.0), - child: Text( - locale.translate('pages.data_viz.thanks'), - style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey600), - ), - ), - ..._dataVizCards, - ], - ), - ), - ), - ], - ), - ), - ); - } - - // The list of cards, depending on what measures are defined in the study. - List get _dataVizCards { - final List widgets = []; - - // Show user task progress, if study has any tasks. - if (widget.model.hasUserTasks) { - widgets.add(StudyProgressCardWidget(widget.model.studyProgressCardDataModel)); - } - - // Show HR if there is a POLAR or MOVESENSE device in the study - if (widget.model.hasHeartRateMeasure) { - widgets.add(HeartRateOuterStatefulWidget(widget.model.heartRateCardDataModel)); - } - - // check to show surveys stats - if (widget.model.hasUserTasks) { - widgets.add(SurveyCard(widget.model.surveysCardDataModel)); - } - - List mediaModelsList = []; - - // check what media types are in the study and add them to de media card - if (widget.model.hasAudioMeasure) { - mediaModelsList.add(widget.model.audioCardDataModel); - } - if (widget.model.hasVideoMeasure) { - mediaModelsList.add(widget.model.videoCardDataModel); - } - if (widget.model.hasImageMeasure) { - mediaModelsList.add(widget.model.imageCardDataModel); - } - if (mediaModelsList.isNotEmpty) { - widgets.add(MediaCardWidget(mediaModelsList)); - } - - if (widget.model.hasStepsMeasure) { - widgets.add(StepsCardWidget(widget.model.stepsCardDataModel)); - } - if (widget.model.hasActivityMeasure) { - widgets.add(ActivityCard(widget.model.activityCardDataModel)); - } - if (widget.model.hasMobilityMeasure) { - widgets.add(MobilityCard(widget.model.mobilityCardDataModel)); - widgets.add(DistanceCard(widget.model.mobilityCardDataModel)); - } - - return widgets.toSet().toList(); - } -} diff --git a/lib/ui/pages/device_list_page.dart b/lib/ui/pages/device_list_page.dart index 58d7d6da..61c79052 100644 --- a/lib/ui/pages/device_list_page.dart +++ b/lib/ui/pages/device_list_page.dart @@ -38,62 +38,28 @@ class DeviceListPageState extends State { @override Widget build(BuildContext context) { - RPLocalizations locale = RPLocalizations.of(context)!; + final locale = RPLocalizations.of(context)!; + final colors = Theme.of(context).extension()!; + return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: colors.backgroundGray, body: SafeArea( child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: const CarpAppBar(hasProfileIcon: true), ), - Container( - color: Colors.transparent, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: Align( - alignment: Alignment.centerLeft, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - locale.translate('pages.devices.title'), - style: fs24fw700.copyWith( - color: Theme.of(context).extension()!.grey900, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ), - Container( - color: Colors.transparent, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Align( - alignment: Alignment.centerLeft, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - locale.translate("pages.devices.message"), - style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey600), - ), - const SizedBox(height: 15), - ], - ), - ), + CarpPageTitle(locale.translate('app_home.nav_bar_item.connections')), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Text( + locale.translate("pages.devices.message"), + style: fs14fw600.copyWith(color: colors.grey600, height: 1.4), ), ), Expanded( - flex: 4, child: RefreshIndicator( onRefresh: _refreshStatuses, child: CustomScrollView( @@ -102,6 +68,7 @@ class DeviceListPageState extends State { ..._smartphoneDeviceList(locale), if (_hardwareDevices.isNotEmpty) ..._hardwareDevicesList(locale), if (_onlineServices.isNotEmpty) ..._onlineServicesList(locale), + const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ), ), @@ -223,50 +190,54 @@ class DeviceListPageState extends State { String? subtitle, void Function()? onTap, Widget? trailing, - }) => ListTile( - contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - enableFeedback: enableFeedback, - leading: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [leading!], - ), - title: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, + }) { + final colors = Theme.of(context).extension()!; + return ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + minVerticalPadding: 0, + enableFeedback: enableFeedback, + // The tinted rounded-square badge shared with the task and feed cards. + leading: Container( + width: 40, + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: (leading!.color ?? colors.primary!).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(leading.icon, color: leading.color ?? colors.primary, size: 20), + ), + title: Row( children: [ - Text(title!.$1, style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900)), - SizedBox(width: 6), - if (title.$2 != null && title.$2! > 0) BatteryPercentage(batteryLevel: title.$2 ?? 0), + Flexible( + child: Text( + title!.$1, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: fs16fw600.copyWith(color: colors.grey900), + ), + ), + if (title.$2 != null && title.$2! > 0) ...[ + const SizedBox(width: 6), + BatteryPercentage(batteryLevel: title.$2!), + ], ], ), - ), - subtitle: subtitle != null && subtitle.isNotEmpty - ? Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - subtitle, - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), - ), + subtitle: subtitle != null && subtitle.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: fs12fw600.copyWith(color: colors.grey600), ), - ], - ) - : null, - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [?trailing], - ), - onTap: onTap, - ); + ) + : null, + trailing: trailing, + onTap: onTap, + ); + } Widget _devicesPageCardStream(Stream stream, T? initialData, Widget Function() childBuilder) => Center( child: StudiesMaterial( @@ -286,7 +257,19 @@ class DeviceListPageState extends State { if (!(await service.deviceManager.hasPermissions())) { if (service.type == HealthService.DEVICE_TYPE) { - Navigator.push(context, MaterialPageRoute(builder: (context) => HealthServiceConnectPage())); + Navigator.of( + context, + rootNavigator: true, + ).push(MaterialPageRoute(builder: (context) => HealthServiceConnectPage())); + } else if (service.type == LocationService.DEVICE_TYPE) { + final status = await Permission.locationWhenInUse.request(); + // Permanently denied/restricted: the OS won't prompt again, so send the + // user to Settings instead of silently doing nothing. + if (status.isPermanentlyDenied || status.isRestricted) { + await openAppSettings(); + return; + } + if (!status.isGranted) return; } else { await service.deviceManager.requestPermissions(); } @@ -320,8 +303,7 @@ class DeviceListPageState extends State { if (disconnect) await device.disconnectFromDevice(); } else { final hasSeenInstructions = LocalSettings().hasSeenBluetoothConnectionInstructions; - Navigator.push( - context, + Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (context) => BluetoothConnectionPage( hasSeenInstructions ? CurrentStep.scan : CurrentStep.instructions, diff --git a/lib/ui/pages/devices_page.list_title.dart b/lib/ui/pages/devices_page.list_title.dart index 1afaf6ba..c202dcb1 100644 --- a/lib/ui/pages/devices_page.list_title.dart +++ b/lib/ui/pages/devices_page.list_title.dart @@ -9,18 +9,6 @@ class DevicesPageListTitle extends StatelessWidget { final DevicesPageTypes type; @override - Widget build(BuildContext context) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), - child: Text( - locale.translate("pages.devices.${type.name}.title").toUpperCase(), - style: fs16fw400ls1.copyWith( - color: Theme.of(context).extension()!.grey900, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } + Widget build(BuildContext context) => + SliverToBoxAdapter(child: CarpSectionTitle(locale.translate("pages.devices.${type.name}.title"))); } diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index 31f21a0d..569d1a41 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -1,116 +1,402 @@ part of carp_study_app; -/// The home page of the app - the navigation bar around the shell pages. -/// -/// Shown once the onboarding process is done. All setup orchestration -/// (consent gating, study configuration, starting sensing) is owned by the -/// [AppBloc] and the router redirect - not this page. -class HomePage extends StatefulWidget { +/// The redesigned home page (design 2.0) - the landing tab of the app shell. +class HomePage extends StatelessWidget { + static const String route = '/home'; final HomePageViewModel model; - final Widget child; - const HomePage({required this.model, required this.child, super.key}); + const HomePage({required this.model, super.key}); @override - HomePageState createState() => HomePageState(); -} + Widget build(BuildContext context) { + final colors = Theme.of(context).extension()!; + return Scaffold( + backgroundColor: colors.backgroundGray, + body: SafeArea( + child: ListenableBuilder( + listenable: model, + builder: (context, _) => ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + child: CarpAppBar(hasProfileIcon: true), + ), + if (!model.isLoaded) + _skeleton(colors) + else ...[ + AppUpdateCard(model: model), + StudyAboutCard(model: model), + CarpSectionTitle('Connections'), + ConnectionsStatusCard(model: model), + CarpSectionTitle('Your progress'), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _activeDaysTile(context, colors)), + Expanded(child: _taskStatusTile(context, colors)), + ], + ), + ), + if (model.messages.isNotEmpty) ...[ + CarpSectionTitle('Feeds'), + for (final message in model.messages) _feedCard(context, colors, message), + ], + ], + ], + ), + ), + ), + ); + } -class HomePageState extends State { - @override - void initState() { - super.initState(); - widget.model.addListener(_onModelChanged); + /// Shimmer placeholders mirroring the page layout (about card, connections, + /// progress tiles, feeds), shown until the study is loaded. + Widget _skeleton(CarpColors colors) { + Widget box(double height, {EdgeInsetsGeometry? margin}) => Container( + height: height, + margin: margin ?? const EdgeInsets.only(left: 16, right: 16, bottom: 16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)), + ); + Widget bar() => Align( + alignment: Alignment.centerLeft, + child: Container( + height: 18, + width: 120, + margin: const EdgeInsets.only(left: 16, bottom: 8, top: 8), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4)), + ), + ); + + return Shimmer.fromColors( + baseColor: colors.grey200!, + highlightColor: colors.grey100!, + child: Column( + children: [ + box(195), + bar(), + box(88), + bar(), + Row( + children: [ + Expanded(child: box(180, margin: const EdgeInsets.only(left: 16, right: 6, bottom: 16))), + Expanded(child: box(180, margin: const EdgeInsets.only(left: 6, right: 16, bottom: 16))), + ], + ), + bar(), + box(140), + box(140), + ], + ), + ); } - @override - void dispose() { - widget.model.removeListener(_onModelChanged); - super.dispose(); + /// "Active Days in Study" tile: total active days, a dot per day for the + /// last 7 days (filled if at least one task was done), and a stats link. + Widget _activeDaysTile(BuildContext context, CarpColors colors) { + return _statTile( + colors, + icon: Icons.calendar_today_outlined, + iconColor: colors.primary!, + label: 'Active Days in Study', + value: Text('${model.activeDaysInStudy}', style: fs30fw800.copyWith(color: colors.grey900, fontSize: 28)), + footer: Row( + children: [ + for (final active in model.lastWeekActivity) + Container( + width: 10, + height: 10, + margin: const EdgeInsets.only(right: 5), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active ? colors.primary : null, + border: Border.all(color: colors.primary!, width: 1.5), + ), + ), + ], + ), + linkLabel: 'View Statistics', + onLink: () => context.go(StatisticsPage.route), + margin: const EdgeInsets.only(left: 16, right: 6, bottom: 16), + ); + } + + /// "Task status" tile: completed and pending counts and a tasks link. + Widget _taskStatusTile(BuildContext context, CarpColors colors) { + Widget count(int n, String label) => Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('$n'.padLeft(2, '0'), style: fs30fw800.copyWith(color: colors.grey900, fontSize: 28)), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(label, style: fs14fw600.copyWith(color: colors.grey600)), + ), + ], + ); + return _statTile( + colors, + icon: Icons.task_alt, + iconColor: colors.warningColor!, + label: 'Task status', + value: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [count(model.taskCompleted, 'Completed'), count(model.taskPending, 'Pending')], + ), + linkLabel: 'Complete Tasks', + onLink: () => context.go(TaskListPage.route), + margin: const EdgeInsets.only(left: 6, right: 16, bottom: 16), + ); + } + + // Shared stat tile chrome: label + icon badge on top, main content, optional + // footer row, and a link at the bottom. + Widget _statTile( + CarpColors colors, { + required IconData icon, + required Color iconColor, + required String label, + required Widget value, + Widget? footer, + required String linkLabel, + required VoidCallback onLink, + required EdgeInsetsGeometry margin, + }) { + return StudiesMaterial( + backgroundColor: colors.grey50!, + margin: margin, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text(label, style: fs14fw600.copyWith(color: colors.grey600)), + ), + _iconBadge(icon, iconColor), + ], + ), + const SizedBox(height: 8), + value, + if (footer != null) ...[const SizedBox(height: 8), footer], + // Push the link to the card bottom so both tiles' links align. + const Spacer(), + const SizedBox(height: 8), + InkWell( + onTap: onLink, + child: Text( + linkLabel, + style: fs14fw600.copyWith( + color: colors.primary, + decoration: TextDecoration.underline, + decorationColor: colors.primary, + ), + ), + ), + ], + ), + ), + ); } - void _onModelChanged() { - if (widget.model.shouldPromptHealthConnectInstall && mounted) { - widget.model.healthConnectPromptShown(); - showDialog( - context: context, - barrierDismissible: true, - builder: (context) => InstallHealthConnectDialog(context), - ); - } + /// A message (announcement / news / article) from the backend, tappable to + /// open its details page. + Widget _feedCard(BuildContext context, CarpColors colors, Message message) { + final locale = RPLocalizations.of(context)!; + final subTitle = message.subTitle ?? ''; + final body = message.message ?? ''; + + return StudiesMaterial( + backgroundColor: colors.grey50!, + child: InkWell( + onTap: () => context.push('${MessageDetailsPage.route}/${message.id}'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + locale.translate(message.title ?? ''), + style: fs18fw700.copyWith(color: colors.grey900), + ), + ), + _iconBadge(message.type.icon, colors.primary!), + ], + ), + if (subTitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(locale.translate(subTitle), style: fs14fw600.copyWith(color: colors.grey600)), + ], + if (body.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + locale.translate(body), + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: fs16fw400.copyWith(color: colors.grey900), + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Icon(Icons.access_time, size: 14, color: colors.grey500), + const SizedBox(width: 4), + Text(timeago.format(message.timestamp.toLocal()), style: fs12fw600.copyWith(color: colors.grey500)), + ], + ), + ], + ), + ), + ), + ); } + // Rounded-square badge: tinted background with the icon in the accent color. + Widget _iconBadge(IconData icon, Color color) { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration(color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(10)), + child: Icon(icon, color: color, size: 18), + ); + } +} + +/// Home banner shown only when a newer app version is available in the store. +/// A slim text row with a "Get" button that opens the store. +class AppUpdateCard extends StatelessWidget { + final HomePageViewModel model; + const AppUpdateCard({required this.model, super.key}); + @override Widget build(BuildContext context) { - RPLocalizations locale = RPLocalizations.of(context)!; + if (!model.appUpdateAvailable) return const SizedBox.shrink(); + final colors = Theme.of(context).extension()!; + final locale = RPLocalizations.of(context)!; - return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, - body: SafeArea(child: widget.child), - bottomNavigationBar: BottomNavigationBar( - backgroundColor: Theme.of(context).extension()!.white, - type: BottomNavigationBarType.fixed, - selectedItemColor: Theme.of(context).extension()!.primary, - items: [ - BottomNavigationBarItem( - icon: const Icon(Icons.announcement), - label: locale.translate('app_home.nav_bar_item.about'), - activeIcon: const Icon(Icons.announcement), - ), - BottomNavigationBarItem( - icon: const Icon(Icons.playlist_add_check), - label: locale.translate('app_home.nav_bar_item.tasks'), - activeIcon: const Icon(Icons.playlist_add_check), - ), - BottomNavigationBarItem( - icon: const Icon(Icons.leaderboard), - label: locale.translate('app_home.nav_bar_item.data'), - activeIcon: const Icon(Icons.leaderboard), - ), - BottomNavigationBarItem( - icon: const Icon(Icons.devices_other), - label: locale.translate('app_home.nav_bar_item.devices'), - activeIcon: const Icon(Icons.devices_other), - ), - ], - currentIndex: _calculateSelectedIndex(context), - onTap: (int idx) => _onItemTapped(idx, context), + return StudiesMaterial( + backgroundColor: colors.grey50!, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Row( + children: [ + Expanded( + child: Text(locale.translate('pages.about.app_update'), style: fs14fw600.copyWith(color: colors.grey900)), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: model.openAppStore, + style: FilledButton.styleFrom( + backgroundColor: colors.primary, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 20), + ), + child: const Text('Get'), + ), + ], + ), ), ); } +} + +/// Gradient home card with the study title, its deployment status as a bubble, +/// a short description (max 2 lines), and a link to the full about page. +class StudyAboutCard extends StatelessWidget { + final HomePageViewModel model; + const StudyAboutCard({required this.model, super.key}); + + static const Map _status = { + StudyDeploymentStatusTypes.Invited: (Color(0xFFF59E0B), 'INVITED'), + StudyDeploymentStatusTypes.DeployingDevices: (Color(0xFFF59E0B), 'DEPLOYING'), + StudyDeploymentStatusTypes.Running: (Color(0xFF22C55E), 'RUNNING'), + StudyDeploymentStatusTypes.Stopped: (Color(0xFFF43F5E), 'STOPPED'), + }; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension()!; + final primary = colors.primary!; + final locale = RPLocalizations.of(context)!; - static int _calculateSelectedIndex(BuildContext context) { - final String location = GoRouterState.of(context).matchedLocation; - if (location.startsWith(StudyPage.route)) { - return 0; - } - if (location.startsWith(TaskListPage.route)) { - return 1; - } - if (location.startsWith(DataVisualizationPage.route)) { - return 2; - } - if (location.startsWith(DeviceListPage.route)) { - return 3; - } - return -1; + return StudiesMaterial( + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color.lerp(primary, Colors.white, 0.3)!, primary], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // The status arrives async after the card is first shown; keep the + // bubble slot occupied (invisible placeholder) so the card height + // doesn't jump when it lands. + Visibility( + visible: model.deploymentStatus != null, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: Row(children: [_statusBubble(model.deploymentStatus ?? StudyDeploymentStatusTypes.Running)]), + ), + const SizedBox(height: 8), + Text(locale.translate(model.studyTitle), style: fs22fw700.copyWith(color: Colors.white)), + const SizedBox(height: 8), + Text( + locale.translate(model.studyDescription), + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: fs14fw600.copyWith(color: Colors.white.withValues(alpha: 0.9)), + ), + const SizedBox(height: 8), + InkWell( + onTap: () => context.push(StudyDetailsPage.route), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'About the study', + style: fs14fw600.copyWith( + color: Colors.white, + decoration: TextDecoration.underline, + decorationColor: Colors.white, + ), + ), + const SizedBox(width: 6), + const Icon(Icons.arrow_forward_ios, size: 14, color: Colors.white), + ], + ), + ), + ], + ), + ), + ); } - void _onItemTapped(int index, BuildContext context) { - switch (index) { - case 0: - context.go(StudyPage.route); - break; - case 1: - context.go(TaskListPage.route); - break; - case 2: - context.go(DataVisualizationPage.route); - break; - case 3: - context.go(DeviceListPage.route); - break; - case -1: - context.go(CarpAppState.homeRoute); - break; - } + // Transparent pill with the status dot and label in white. + Widget _statusBubble(StudyDeploymentStatusTypes status) { + final (dot, label) = _status[status] ?? (const Color(0xFFF43F5E), ''); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(100)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle, size: 8, color: dot), + const SizedBox(width: 6), + Text(label, style: fs12fw600.copyWith(color: Colors.white)), + ], + ), + ); } } diff --git a/lib/ui/pages/informed_consent_page.dart b/lib/ui/pages/informed_consent_page.dart index d9afa95c..d5394a5e 100644 --- a/lib/ui/pages/informed_consent_page.dart +++ b/lib/ui/pages/informed_consent_page.dart @@ -1,66 +1,29 @@ part of carp_study_app; -class InformedConsentPage extends StatefulWidget { - static const String route = '/study/consent'; +/// The informed consent document, for the user to read and sign. +/// +/// Pushed on top of the current page by [CarpAppShell] - never used as a +/// redirect target. That keeps a route below it, so the cancel button in +/// research_package (which pops this route itself) behaves like any other pop +/// and returns "not signed" to the caller. Pops `true` once signed. +class InformedConsentPage extends StatelessWidget { + static const String route = '/consent'; final InformedConsentViewModel model; - const InformedConsentPage({super.key, required this.model}); - - @override - InformedConsentState createState() => InformedConsentState(); -} - -class InformedConsentState extends State { - final GlobalKey _scaffoldKey = GlobalKey(); - - // Tracks whether the user actually completed consent. Anything else that - // tears down this page (cancel dialog, system back, programmatic redirect) - // is treated as "user did not consent" and leaveStudy() is called from - // dispose(). Set BEFORE awaiting the upload so a router-driven redirect - // mid-await doesn't get misread as a cancel. - bool _submitted = false; - - Future resultCallback(RPTaskResult result) async { - _submitted = true; - await widget.model.informedConsentHasBeenAccepted(result); - if (!mounted) return; - context.go(CarpAppState.homeRoute); - } - - @override - void dispose() { - // Bypassing onCancel entirely is intentional — see issue carp-dk/research.package#168. - if (!_submitted) { - widget.model.abandonConsent(); - } - super.dispose(); - } + const InformedConsentPage({required this.model, super.key}); @override Widget build(BuildContext context) { - RPLocalizations localization = RPLocalizations.of(context)!; + // The caller resolves consent - and loads the document - before pushing + // this page, so a missing one means the route was entered some other way. + final document = model.informedConsent; + if (document == null) return const ErrorPage(); return Scaffold( - key: _scaffoldKey, - body: FutureBuilder( - future: widget.model.getInformedConsent(localization.locale).then((document) async { - // No consent document configured for this study → mark accepted - // and navigate to /study. Set _submitted so dispose() doesn't tear - // the study back down. - if (document == null && !_submitted) { - _submitted = true; - await widget.model.acceptWithoutDocument(); - if (mounted) context.go(CarpAppState.homeRoute); - } - return document; - }), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - if (snapshot.hasData) { - return RPUITask(task: snapshot.data!, onSubmit: resultCallback); - } - } - - return const Center(child: CircularProgressIndicator()); + body: RPUITask( + task: document, + onSubmit: (result) async { + await model.accept(result); + if (context.mounted) context.pop(true); }, ), ); diff --git a/lib/ui/pages/invitation_details_page.dart b/lib/ui/pages/invitation_details_page.dart index fc709221..4e70c246 100644 --- a/lib/ui/pages/invitation_details_page.dart +++ b/lib/ui/pages/invitation_details_page.dart @@ -10,7 +10,10 @@ class InvitationDetailsPage extends StatelessWidget { @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; - var invitation = model.getInvitation(invitationId); + final invitation = model.getInvitation(invitationId); + if (invitation == null) { + return Scaffold(backgroundColor: Theme.of(context).extension()!.backgroundGray); + } return Scaffold( backgroundColor: Theme.of(context).extension()!.backgroundGray, @@ -134,9 +137,12 @@ class InvitationDetailsPage extends StatelessWidget { height: 56, decoration: BoxDecoration(color: const Color(0xff006398), borderRadius: BorderRadius.circular(100)), child: TextButton( + // Consent is gated by the app shell, which this navigation + // mounts - declining there leaves the study and the router + // brings the user back to the invitation list. onPressed: () { model.accept(invitation); - context.go(StudyPage.route); + context.go(HomePage.route); }, child: Text( locale.translate("invitation.accept_invite"), diff --git a/lib/ui/pages/invitation_list_page.dart b/lib/ui/pages/invitation_list_page.dart index f21bda27..d0a0c770 100644 --- a/lib/ui/pages/invitation_list_page.dart +++ b/lib/ui/pages/invitation_list_page.dart @@ -114,7 +114,7 @@ class InvitationMaterial extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), child: InkWell( onTap: () { - context.push('${InvitationDetailsPage.route}/${invitation.participation.participantId}'); + context.push('${InvitationDetailsPage.route}/${invitation.studyDeploymentId}'); }, child: Padding( padding: const EdgeInsets.all(16.0), diff --git a/lib/ui/pages/login_page.dart b/lib/ui/pages/login_page.dart index 2e1d9bb9..0b1cbaf5 100644 --- a/lib/ui/pages/login_page.dart +++ b/lib/ui/pages/login_page.dart @@ -58,10 +58,6 @@ class _LoginPageState extends State { final invitations = bloc.appViewModel.invitationsListViewModel; await invitations.loadInvitations(); if (!context.mounted) return; - logApp( - 'LoginPage - sign-in success, loaded ${invitations.invitations.length} invitation(s), ' - 'navigating to landingRoute=${invitations.landingRoute}', - ); context.go(invitations.landingRoute); } else if (result == SignInResult.offline) { showDialog( diff --git a/lib/ui/pages/message_details_page.dart b/lib/ui/pages/message_details_page.dart index 20893829..cbe57601 100644 --- a/lib/ui/pages/message_details_page.dart +++ b/lib/ui/pages/message_details_page.dart @@ -1,5 +1,8 @@ part of carp_study_app; +/// The details screen for a [Message] (announcement / news / article). +/// +/// Pushed full-screen on the root navigator, in the design 2.0 card style. class MessageDetailsPage extends StatelessWidget { static const String route = '/message'; final String messageId; @@ -8,106 +11,114 @@ class MessageDetailsPage extends StatelessWidget { @override Widget build(BuildContext context) { - RPLocalizations locale = RPLocalizations.of(context)!; - - Message message = bloc.appViewModel.studyPageViewModel.messageById(messageId); + final locale = RPLocalizations.of(context)!; + final colors = Theme.of(context).extension()!; + final message = bloc.appViewModel.studyPageViewModel.messageById(messageId); + final subTitle = message.subTitle ?? ''; + final body = message.message ?? ''; + final hasImage = message.image != null && message.image!.isNotEmpty; return Scaffold( + backgroundColor: colors.backgroundGray, body: SafeArea( - child: Container( - color: Theme.of(context).colorScheme.secondary, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), - child: const CarpAppBar(hasProfileIcon: true), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 8), + child: TextButton.icon( + onPressed: () => context.canPop() ? context.pop() : context.go(CarpAppState.homeRoute), + icon: Icon(Icons.arrow_back_ios, size: 18, color: colors.primary), + label: Text( + locale.translate('app_home.nav_bar_item.home'), + style: fs16fw600.copyWith(color: colors.primary), + ), ), - Row( + ), + Flexible( + child: ListView( + padding: const EdgeInsets.only(top: 8, bottom: 24), children: [ - IconButton( - padding: const EdgeInsets.only(left: 26, right: 10, top: 16, bottom: 16), - icon: Icon(Icons.arrow_back_ios, color: Theme.of(context).extension()!.grey600), - onPressed: () { - if (context.canPop()) { - context.pop(); - } else { - context.go(CarpAppState.homeRoute); - } - }, - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0), - child: Text( - locale.translate(message.title!), - style: fs20fw700.copyWith(color: Theme.of(context).extension()!.grey900), - ), - ), - Spacer(), - Padding( - padding: const EdgeInsets.only(right: 24), - child: Material( - color: Theme.of(context).extension()!.primary, - borderRadius: BorderRadius.circular(100.0), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), - child: Text( - locale.translate(message.type.toString().split('.').last.toLowerCase()), - style: fs16fw600.copyWith(color: Colors.white), - ), - ), - ), - ), - ], - ), - Flexible( - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - children: [ - message.subTitle != null - ? Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 6.0), - child: Text( - locale.translate(message.subTitle!), - style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey700), - ), - ) - : const SizedBox.shrink(), - if (message.image != null && message.image!.isNotEmpty) - LayoutBuilder( - builder: (context, constraints) { - final screenHeight = MediaQuery.of(context).size.height; - final screenWidth = MediaQuery.of(context).size.height; - return ConstrainedBox( - constraints: BoxConstraints(maxWidth: screenHeight, maxHeight: screenWidth), + StudiesMaterial( + backgroundColor: colors.grey50!, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasImage) + SizedBox( + width: double.infinity, + height: 180, child: FittedBox( - fit: BoxFit.contain, + fit: BoxFit.cover, + clipBehavior: Clip.hardEdge, child: bloc.appViewModel.studyPageViewModel.getMessageImage(message.image), ), - ); - }, - ), - // DetailsBanner(message.title ?? '', message.image), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (message.message != null) - Text( - locale.translate(message.message!), - style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey900), - textAlign: TextAlign.justify, - ), - ], - ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _typeChip(context, colors, message.type), + const SizedBox(height: 12), + Text( + locale.translate(message.title ?? ''), + style: fs20fw700.copyWith(color: colors.grey900), + ), + if (subTitle.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(locale.translate(subTitle), style: fs14fw600.copyWith(color: colors.grey600)), + ], + if (body.isNotEmpty) ...[ + // The divider only earns its place when it has a + // subtitle to separate the body from. + if (subTitle.isNotEmpty) ...[ + const SizedBox(height: 16), + Divider(height: 1, color: colors.grey200), + ], + const SizedBox(height: 12), + Text( + locale.translate(body), + style: fs16fw400.copyWith(color: colors.grey900, height: 1.5), + ), + ], + ], + ), + ), + ], ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), ), ); } + + /// A pill with the message type's icon and label, in the accent colour. + Widget _typeChip(BuildContext context, CarpColors colors, MessageType type) { + final locale = RPLocalizations.of(context)!; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colors.primary!.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(100), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(type.icon, size: 14, color: colors.primary), + const SizedBox(width: 6), + Flexible( + child: Text( + locale.translate(type.name.toLowerCase()), + style: fs12fw600.copyWith(color: colors.primary), + ), + ), + ], + ), + ); + } } diff --git a/lib/ui/pages/profile_page.dart b/lib/ui/pages/profile_page.dart index 67cdb267..0c1a42d4 100644 --- a/lib/ui/pages/profile_page.dart +++ b/lib/ui/pages/profile_page.dart @@ -24,187 +24,256 @@ class ProfilePageState extends State { @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; + final colors = Theme.of(context).extension()!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.grey100, - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const SizedBox(height: 35), - // Top bar - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton.icon( - onPressed: () {}, - icon: Icon(Icons.account_circle, color: Theme.of(context).primaryColor, size: 30), - label: Text( - locale.translate("pages.profile.title"), - style: fs20fw700.copyWith(color: Theme.of(context).primaryColor), + backgroundColor: colors.backgroundGray, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(locale.translate("pages.profile.title"), style: fs24fw700.copyWith(color: colors.grey900)), + IconButton( + icon: Icon(Icons.close, color: colors.primary, size: 28), + tooltip: locale.translate('Back'), + onPressed: () => context.pop(), ), - ), - IconButton( - icon: Icon(Icons.close, color: Theme.of(context).primaryColor, size: 30), - tooltip: locale.translate('Back'), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ], + ], + ), ), - ), - widget.model.isAnonymous ? AnonymousCard() : SizedBox.shrink(), - Flexible( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), + if (widget.model.isAnonymous) AnonymousCard(), + Flexible( child: ListView( - padding: EdgeInsets.zero, + padding: const EdgeInsets.only(bottom: 24), children: [ - _buildSectionCard(context, [ - _buildListTile(locale.translate('pages.profile.username'), widget.model.username), - _buildListTile(locale.translate('pages.profile.account_id'), widget.model.userId), - _buildListTile( + _sectionCard(colors, [ + _fieldTile(colors, locale.translate('pages.profile.username'), widget.model.username), + _fieldTile( + colors, + locale.translate('pages.profile.account_id'), + widget.model.userId, + copyable: true, + ), + _fieldTile( + colors, locale.translate('pages.profile.full_name'), widget.model.isAnonymous ? locale.translate('pages.about.anonymous.anonymous') : widget.model.fullName, ), - _buildListTile( + _fieldTile( + colors, locale.translate('pages.profile.email'), widget.model.isAnonymous ? locale.translate('pages.about.anonymous.anonymous') : widget.model.email, ), ]), - _buildSectionCard(context, [ - _buildListTile(locale.translate('pages.profile.study_id'), widget.model.studyId), - _buildListTile( + _sectionHeader(colors, 'Study'), + _sectionCard(colors, [ + _fieldTile( + colors, + locale.translate('pages.profile.study_id'), + widget.model.studyId, + copyable: true, + ), + _fieldTile( + colors, locale.translate('pages.profile.study_deployment_id'), widget.model.studyDeploymentId, + copyable: true, ), - _buildListTile( + _fieldTile( + colors, locale.translate('pages.profile.study_name'), locale.translate(widget.model.studyDeploymentTitle), + copyable: true, ), - _buildListTile(locale.translate('pages.profile.participant_id'), widget.model.participantId), - _buildListTile(locale.translate('pages.profile.participant_role'), widget.model.participantRole), - _buildListTile(locale.translate('pages.profile.device_role'), widget.model.deviceRole), - ]), - _buildSectionCard(context, [ - _buildListTile(locale.translate('pages.profile.app_version'), appVersion), - _buildListTile(locale.translate('pages.profile.app_version_code'), buildNumber), - _buildListTile(locale.translate('pages.profile.server_name'), widget.model.currentServer), - _buildListTile(locale.translate('pages.profile.device_id'), widget.model.deviceID), - ]), - _buildSectionCard(context, [ - _buildActionListTile( - leading: Icon(Icons.mail, color: Theme.of(context).primaryColor), - trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), - title: locale.translate('pages.profile.contact'), - onTap: () async { - _sendEmailToContactResearcher( - locale.translate(widget.model.responsibleEmail), - 'Support for study: ${locale.translate(widget.model.studyDeploymentTitle)} - User: ${widget.model.username}', - ); - }, - ), - _buildActionListTile( - leading: Icon(Icons.policy, color: Theme.of(context).primaryColor), - trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), - title: locale.translate('pages.profile.privacy'), - onTap: () async { - try { - launchUrl(Uri.parse(CarpBackend.carpPrivacyUrl)); - } finally {} - }, + _fieldTile( + colors, + locale.translate('pages.profile.participant_id'), + widget.model.participantId, + copyable: true, ), - _buildActionListTile( - leading: Icon(Icons.public, color: Theme.of(context).primaryColor), - trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), - title: locale.translate('pages.profile.study_website'), - onTap: () async { - try { - launchUrl(Uri.parse(CarpBackend.carpWebsiteUrl)); - } finally {} - }, + _fieldTile( + colors, + locale.translate('pages.profile.participant_role'), + widget.model.participantRole, ), + _fieldTile(colors, locale.translate('pages.profile.device_role'), widget.model.deviceRole), ]), - _buildSectionCard(context, [ - _buildActionListTile( - leading: const Icon(Icons.logout, color: CACHET.RED_1), - title: locale.translate('pages.profile.leave_study'), - onTap: () { - _showLeaveStudyConfirmationDialog(); - }, - ), + _sectionHeader(colors, 'App'), + _sectionCard(colors, [ + _fieldTile(colors, locale.translate('pages.profile.app_version'), appVersion), + _fieldTile(colors, locale.translate('pages.profile.app_version_code'), buildNumber), + _fieldTile(colors, locale.translate('pages.profile.server_name'), widget.model.currentServer), + _fieldTile(colors, locale.translate('pages.profile.device_id'), widget.model.deviceID), ]), - _buildSectionCard(context, [ - _buildActionListTile( - leading: const Icon(Icons.power_settings_new, color: CACHET.RED_1), - title: locale.translate('pages.profile.log_out'), - onTap: () async { - bool isConnected = await widget.model.checkConnectivity(); - if (isConnected) { - _showLogoutConfirmationDialog(); - } else { - _showEnableInternetConnectionDialog(); - } - }, + _sectionHeader(colors, 'Resources'), + _actionCard( + colors, + icon: Icons.policy_outlined, + iconColor: colors.primary!, + title: locale.translate('pages.profile.privacy'), + hasChevron: true, + onTap: () async { + try { + launchUrl(Uri.parse(CarpBackend.carpPrivacyUrl)); + } finally {} + }, + ), + _actionCard( + colors, + icon: Icons.public, + iconColor: colors.primary!, + title: locale.translate('pages.profile.study_website'), + hasChevron: true, + onTap: () async { + try { + launchUrl(Uri.parse(CarpBackend.carpWebsiteUrl)); + } finally {} + }, + ), + _actionCard( + colors, + icon: Icons.mail_outline, + iconColor: colors.primary!, + title: locale.translate('pages.profile.contact'), + hasChevron: true, + onTap: () => _sendEmailToContactResearcher( + locale.translate(widget.model.responsibleEmail), + 'Support for study: ${locale.translate(widget.model.studyDeploymentTitle)} - User: ${widget.model.username}', ), - ]), + ), + _actionCard( + colors, + icon: Icons.download, + iconColor: colors.primary!, + title: locale.translate('pages.profile.download_consent'), + hasChevron: true, + onTap: _downloadInformedConsent, + ), + const SizedBox(height: 8), + _actionCard( + colors, + icon: Icons.logout, + iconColor: CACHET.RED_1, + title: locale.translate('pages.profile.leave_study'), + onTap: _showLeaveStudyConfirmationDialog, + ), + _actionCard( + colors, + icon: Icons.power_settings_new, + iconColor: CACHET.RED_1, + title: locale.translate('pages.profile.log_out'), + onTap: () async { + bool isConnected = await widget.model.checkConnectivity(); + if (isConnected) { + _showLogoutConfirmationDialog(); + } else { + _showEnableInternetConnectionDialog(); + } + }, + ), ], ), ), - ), - ], + ], + ), ), ); } - Widget _buildSectionCard(BuildContext context, List children) { - return Card( - color: Theme.of(context).extension()!.grey50, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: ListTile.divideTiles( - context: context, - tiles: children, - color: Theme.of(context).extension()!.grey400, - ).toList(), - ), + Widget _sectionHeader(CarpColors colors, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text(title, style: fs18fw700.copyWith(color: colors.grey900)), + ); + } + + Widget _sectionCard(CarpColors colors, List children) { + return StudiesMaterial( + backgroundColor: colors.grey50!, + child: Column( + children: [ + for (var i = 0; i < children.length; i++) ...[ + children[i], + if (i < children.length - 1) Divider(height: 1, indent: 16, endIndent: 16, color: colors.grey200), + ], + ], ), ); } - Widget _buildListTile(String title, String subtitle) { - return ListTile( - title: Text(title, style: fs12fw600.copyWith(color: CACHET.GREY_6)), - subtitle: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text(subtitle, style: fs14fw600, maxLines: 1), + Widget _fieldTile(CarpColors colors, String label, String value, {bool copyable = false}) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 8, 10), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: fs12fw600.copyWith(color: colors.grey600)), + const SizedBox(height: 2), + Text( + value, + style: fs14fw600.copyWith(color: colors.grey900), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (copyable) + IconButton( + icon: Icon(Icons.copy_outlined, size: 18, color: colors.grey500), + visualDensity: VisualDensity.compact, + onPressed: () { + Clipboard.setData(ClipboardData(text: value)); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$label copied'))); + }, + ) + else + // Keeps copyable and plain rows the same height. + const SizedBox(height: 32), + ], ), ); } - // Helper method to build a ListTile for actions with an icon - Widget _buildActionListTile({ - required Icon leading, + Widget _actionCard( + CarpColors colors, { + required IconData icon, + required Color iconColor, required String title, - Icon? trailing, + bool hasChevron = false, required VoidCallback onTap, }) { - return ListTile( - leading: leading, - title: Text(title, style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey900)), - trailing: trailing, - onTap: onTap, - contentPadding: EdgeInsets.symmetric(vertical: 4, horizontal: 16), + return StudiesMaterial( + backgroundColor: colors.grey50!, + margin: const EdgeInsets.only(bottom: 8, left: 16, right: 16), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon(icon, size: 22, color: iconColor), + const SizedBox(width: 12), + Expanded( + child: Text(title, style: fs16fw600.copyWith(color: colors.grey900)), + ), + if (hasChevron) Icon(Icons.arrow_forward_ios, size: 16, color: colors.grey400), + ], + ), + ), + ), ); } @@ -218,6 +287,10 @@ class ProfilePageState extends State { }); } + /// Fetch the signed consent and hand it to the OS share sheet, so the user + /// can save or send it. Shows a snackbar when there is nothing to download. + Future _downloadInformedConsent() async {} + /// Sends and email to the researcher with the name of the study + user id void _sendEmailToContactResearcher(String email, String subject) async { final url = Uri( @@ -305,19 +378,3 @@ class ProfilePageState extends State { ); } } - -class SlidePageRoute extends PageRouteBuilder { - final Widget page; - SlidePageRoute(this.page) - : super( - pageBuilder: (context, animation, secondaryAnimation) => page, - transitionsBuilder: (context, animation, secondaryAnimation, child) { - var begin = Offset(1.0, 0.0); - var end = Offset.zero; - var curve = Curves.easeInOut; - var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); - var offsetAnimation = animation.drive(tween); - return SlideTransition(position: offsetAnimation, child: child); - }, - ); -} diff --git a/lib/ui/pages/statistics_page.dart b/lib/ui/pages/statistics_page.dart new file mode 100644 index 00000000..2049da57 --- /dev/null +++ b/lib/ui/pages/statistics_page.dart @@ -0,0 +1,65 @@ +part of carp_study_app; + +class StatisticsPage extends StatefulWidget { + static const String route = '/data'; + final StatisticsViewModel model; + const StatisticsPage(this.model, {super.key}); + + @override + State createState() => _StatisticsPageState(); +} + +class _StatisticsPageState extends State { + @override + Widget build(BuildContext context) { + RPLocalizations locale = RPLocalizations.of(context)!; + return Scaffold( + backgroundColor: Theme.of(context).extension()!.backgroundGray, + body: SafeArea( + child: ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + child: CarpAppBar(hasProfileIcon: true), + ), + CarpPageTitle(locale.translate('pages.data_viz.title')), + ..._sections(locale), + ], + ), + ), + ); + } + + /// The sections of the page, each only present when the study actually + /// produces the data behind it - a measure in the protocol, tasks in the + /// queue, or surveys the user has completed. + List _sections(RPLocalizations locale) { + final model = widget.model; + + return [ + if (model.hasUserTasks) ...[ + TaskCompletionCard(model.studyProgressCardDataModel), + CarpSectionTitle(locale.translate('cards.study_progress.title')), + StudyProgressCardWidget(model.studyProgressCardDataModel), + ], + // A donut of a single survey type says nothing - it is one full ring. + if (model.surveysCardDataModel.tasksTable.length >= 2) ...[ + CarpSectionTitle(locale.translate('cards.survey.title')), + SurveyCard(model.surveysCardDataModel, showTitle: false), + ], + if (model.hasHeartRateMeasure) ...[ + CarpSectionTitle(locale.translate('cards.heartrate.title')), + HeartRateCardWidget(model.heartRateCardDataModel), + ], + if (model.hasStepsMeasure) ...[ + CarpSectionTitle(locale.translate('cards.steps.title')), + StepsCardWidget(model.stepsCardDataModel), + ], + if (model.hasActivityMeasure) ...[ + CarpSectionTitle(locale.translate('cards.activity.title')), + ActivityCard(model.activityCardDataModel), + ], + ]; + } +} diff --git a/lib/ui/pages/study_details_page.dart b/lib/ui/pages/study_details_page.dart index a8c8453c..4631c625 100644 --- a/lib/ui/pages/study_details_page.dart +++ b/lib/ui/pages/study_details_page.dart @@ -1,5 +1,10 @@ part of carp_study_app; +/// The "About Study" screen (design 2.0): study image, title, full +/// description with a website link, the study roles, and the study purpose. +/// +/// Pushed full-screen on the root navigator (outside the app shell). +// ponytail: static labels hardcoded EN like the rest of design 2.0; i18n later. class StudyDetailsPage extends StatelessWidget { static const String route = '/study_details'; final StudyPageViewModel model; @@ -8,229 +13,133 @@ class StudyDetailsPage extends StatelessWidget { @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; + final colors = Theme.of(context).extension()!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: colors.backgroundGray, body: SafeArea( - child: Container( - color: Theme.of(context).extension()!.backgroundGray, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), - child: const CarpAppBar(hasProfileIcon: true), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 8), + child: TextButton.icon( + onPressed: () => context.canPop() ? context.pop() : context.go(HomePage.route), + icon: Icon(Icons.arrow_back_ios, size: 18, color: colors.primary), + label: Text( + locale.translate('app_home.nav_bar_item.home'), + style: fs16fw600.copyWith(color: colors.primary), + ), ), - Row( + ), + Flexible( + child: ListView( + padding: const EdgeInsets.only(top: 8, bottom: 24), children: [ - IconButton( - padding: const EdgeInsets.only(left: 26, right: 10, top: 16, bottom: 16), - icon: Icon(Icons.arrow_back_ios, color: Theme.of(context).extension()!.grey600), - onPressed: () { - if (context.canPop()) { - context.pop(); - } else { - context.go(CarpAppState.homeRoute); - } - }, - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0), - child: Text( - locale.translate(model.title), - style: fs20fw700.copyWith(color: Theme.of(context).extension()!.primary), - ), - ), - ], - ), - Flexible( - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: LayoutBuilder( - builder: (context, constraints) { - final screenHeight = MediaQuery.of(context).size.height; - final screenWidth = MediaQuery.of(context).size.height; - return ConstrainedBox( - constraints: BoxConstraints(maxWidth: screenHeight, maxHeight: screenWidth), - child: FittedBox(fit: BoxFit.contain, child: model.image), - ); - }, - ), - ), - _buildSectionCard(context, [ - _buildActionListTile( - context: context, - leading: Icon(Icons.mail, color: Theme.of(context).extension()!.primary), - trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), - title: locale.translate('pages.profile.contact'), - onTap: () async { - _sendEmailToContactResearcher( - locale.translate(model.responsibleEmail), - 'Support for study: ${locale.translate(model.title)} - User: ${model.responsibleName}', - ); - }, - ), - _buildActionListTile( - context: context, - leading: Icon(Icons.policy, color: Theme.of(context).extension()!.primary), - trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), - title: locale.translate('pages.about.study.privacy'), - onTap: () async { - try { - await launchUrl(Uri.parse(locale.translate(model.privacyPolicyUrl))); - } catch (error) { - warning( - "Could not launch study description URL - ${locale.translate(model.privacyPolicyUrl)}", - ); - } - }, - ), - _buildActionListTile( - context: context, - leading: Icon(Icons.public, color: Theme.of(context).extension()!.primary), - trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), - title: locale.translate('pages.about.study.website'), - onTap: () async { - try { - await launchUrl(Uri.parse(locale.translate(model.studyDescriptionUrl))); - } catch (error) { - warning( - "Could not launch study description URL - ${locale.translate(model.studyDescriptionUrl)}", - ); - } - }, - ), - ]), - Padding( - padding: const EdgeInsets.only(top: 16.0), + StudiesMaterial( + backgroundColor: colors.grey50!, + child: Padding( + padding: const EdgeInsets.all(16), child: Column( - mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - locale.translate('widgets.study_card.responsible'), - style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900), - ), - Padding( - padding: const EdgeInsets.only(top: 4.0, bottom: 8), - child: Text( - locale.translate(model.responsibleName), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), - ), - ), - Text( - locale.translate('widgets.study_card.participant_role'), - style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900), - ), - Padding( - padding: const EdgeInsets.only(top: 4.0, bottom: 8), - child: Text( - locale.translate(model.participantRole), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: double.infinity, + height: 180, + child: FittedBox(fit: BoxFit.cover, clipBehavior: Clip.hardEdge, child: model.image), ), ), - Text( - locale.translate('widgets.study_card.device_role'), - style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900), - ), - Padding( - padding: const EdgeInsets.only(top: 4.0, bottom: 8), - child: Text( - locale.translate(model.deviceRole), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), + const SizedBox(height: 16), + Text(locale.translate(model.title), style: fs18fw700.copyWith(color: colors.grey900)), + const SizedBox(height: 8), + Text(locale.translate(model.description), style: fs14fw600.copyWith(color: colors.grey900)), + const SizedBox(height: 12), + InkWell( + onTap: () async { + try { + await launchUrl(Uri.parse(locale.translate(model.studyDescriptionUrl))); + } catch (error) { + warning( + 'Could not launch study description URL - ${locale.translate(model.studyDescriptionUrl)}', + ); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Go to study website', + style: fs14fw600.copyWith( + color: colors.primary, + decoration: TextDecoration.underline, + decorationColor: colors.primary, + ), + ), + const SizedBox(width: 6), + Icon(Icons.open_in_new, size: 16, color: colors.primary), + ], ), ), ], ), ), - Divider(color: Theme.of(context).extension()!.grey300), - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - locale.translate('widgets.study_card.study_description'), - style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900), - ), - Padding( - padding: const EdgeInsets.only(top: 4.0, bottom: 8), - child: Text( - locale.translate(model.description), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), - ), - ), - Text( - locale.translate('widgets.study_card.study_purpose'), - style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900), - ), - Padding( - padding: const EdgeInsets.only(top: 4.0, bottom: 8), - child: Text( - locale.translate(model.purpose), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), - ), - ), - ], - ), + ), + StudiesMaterial( + backgroundColor: colors.grey50!, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _field( + colors, + locale.translate('widgets.study_card.responsible'), + locale.translate(model.responsibleName), + ), + Divider(height: 1, indent: 16, endIndent: 16, color: colors.grey200), + _field( + colors, + locale.translate('widgets.study_card.participant_role'), + locale.translate(model.participantRole), + ), + Divider(height: 1, indent: 16, endIndent: 16, color: colors.grey200), + _field( + colors, + locale.translate('widgets.study_card.device_role'), + locale.translate(model.deviceRole), + ), + ], ), - ], - ), + ), + StudiesMaterial( + backgroundColor: colors.grey50!, + child: _field( + colors, + locale.translate('widgets.study_card.study_purpose'), + locale.translate(model.purpose), + ), + ), + ], ), - ], - ), + ), + ], ), ), ); } - Widget _buildSectionCard(BuildContext context, List children) { - return Card( - margin: EdgeInsets.zero, - color: Theme.of(context).extension()!.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: ListTile.divideTiles( - context: context, - tiles: children, - color: Theme.of(context).extension()!.grey300, - ).toList(), - ), + // Same field style as the profile page: grey label over a dark bold value. + Widget _field(CarpColors colors, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: fs12fw600.copyWith(color: colors.grey600)), + const SizedBox(height: 2), + Text(value, style: fs14fw600.copyWith(color: colors.grey900)), + ], ), ); } - - // Helper method to build a ListTile for actions with an icon - Widget _buildActionListTile({ - required BuildContext context, - required Icon leading, - required String title, - Icon? trailing, - required VoidCallback onTap, - }) { - return ListTile( - leading: leading, - title: Text(title, style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey900)), - trailing: trailing, - onTap: onTap, - contentPadding: EdgeInsets.symmetric(vertical: 4, horizontal: 16), - ); - } - - // Sends and email to the researcher with the name of the study + user id - void _sendEmailToContactResearcher(String email, String subject) async { - final url = Uri( - scheme: 'mailto', - path: email, - queryParameters: {'subject': subject}, - ).toString().replaceAll("+", "%20"); - try { - await launchUrl(Uri.parse(url)); - } finally {} - } } diff --git a/lib/ui/pages/task_list_page.dart b/lib/ui/pages/task_list_page.dart index 54cc55cc..21f26845 100644 --- a/lib/ui/pages/task_list_page.dart +++ b/lib/ui/pages/task_list_page.dart @@ -1,5 +1,7 @@ part of carp_study_app; +/// The "Tasks" tab (design 2.0): a segmented Pending / Completed switch over a +/// list of task cards. class TaskListPage extends StatefulWidget { static const String route = '/tasks'; final TaskListPageViewModel model; @@ -9,37 +11,6 @@ class TaskListPage extends StatefulWidget { TaskListPageState createState() => TaskListPageState(); } -/// Custom SliverAppBarDelegate class -class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { - _SliverAppBarDelegate(this._tabBar); - - final TabBar _tabBar; - - @override - double get minExtent => _tabBar.preferredSize.height; - - @override - double get maxExtent => _tabBar.preferredSize.height; - - @override - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Theme.of(context).extension()!.grey200, - ), - child: _tabBar, - ); - } - - @override - bool shouldRebuild(_SliverAppBarDelegate oldDelegate) { - return false; - } -} - class TaskListPageState extends State with TickerProviderStateMixin { late TabController _tabController; @@ -70,9 +41,10 @@ class TaskListPageState extends State with TickerProviderStateMixi widget.model.autoCompletedTaskShown(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - backgroundColor: Theme.of(context).extension()!.grey700, + backgroundColor: Theme.of(context).extension()!.grey900, content: Text(RPLocalizations.of(context)!.translate('Done!')), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), duration: const Duration(seconds: 1), ), ); @@ -83,441 +55,299 @@ class TaskListPageState extends State with TickerProviderStateMixi @override Widget build(BuildContext context) { - RPLocalizations locale = RPLocalizations.of(context)!; - return DefaultTabController( - length: 2, - child: Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, - body: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), - child: const CarpAppBar(hasProfileIcon: true), - ), - Expanded( - flex: 4, - child: StreamBuilder( - stream: widget.model.userTaskEvents, - builder: (context, snapshot) { - if (widget.model.tasks.isEmpty) { - return _noTasks(context); - } else { - return CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - locale.translate('pages.task_list.title'), - style: fs24fw700.copyWith( - color: Theme.of(context).extension()!.grey900, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - // Scoreboard showing days in study and tasks completed - SliverPadding( - padding: const EdgeInsets.only(top: 4, bottom: 6, left: 40, right: 40), - sliver: ScoreboardCard(widget.model), - ), - // Tab holder - SliverPadding( - padding: const EdgeInsets.only(top: 8, bottom: 24, left: 64, right: 64), - sliver: SliverPersistentHeader( - pinned: true, - delegate: _SliverAppBarDelegate( - TabBar( - controller: _tabController, - labelPadding: const EdgeInsets.only(top: 4, bottom: 4, left: 4, right: 4), - labelColor: Theme.of(context).extension()!.grey900, - unselectedLabelColor: Theme.of(context).extension()!.grey900, - dividerColor: Colors.transparent, - indicator: ShapeDecoration( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - color: Theme.of(context).extension()!.white, - ), - tabs: [ - Container( - width: double.infinity, - child: Tab(text: locale.translate('pages.task_list.pending')), - ), - Container( - width: double.infinity, - child: Tab(text: locale.translate('pages.task_list.completed')), - ), - ], - ), + final locale = RPLocalizations.of(context)!; + final colors = Theme.of(context).extension()!; + + return Scaffold( + backgroundColor: colors.backgroundGray, + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + child: const CarpAppBar(hasProfileIcon: true), + ), + CarpPageTitle(locale.translate('pages.task_list.title')), + Expanded( + child: StreamBuilder( + stream: widget.model.userTaskEvents, + builder: (context, snapshot) { + final pending = widget.model.pendingTasks; + final completed = widget.model.completedTasks; + final showing = _tabController.index == 0 ? pending : completed; + final showParticipantData = _tabController.index == 0 && widget.model.showParticipantDataCard; + + return Column( + children: [ + _segmentedControl(colors, locale, pending.length, completed.length), + Expanded( + child: showing.isEmpty && !showParticipantData + ? _emptyState(colors, locale) + : ListView( + padding: const EdgeInsets.only(bottom: 16), + children: [ + if (showParticipantData) _participantDataCard(colors), + for (final task in showing) _taskCard(context, colors, task), + ], ), - ), - ), - if (widget.model.showParticipantDataCard) - SliverToBoxAdapter(child: _buildParticipantDataCard()), - SliverList( - delegate: SliverChildBuilderDelegate((BuildContext context, int index) { - UserTask userTask = widget.model.tasks[index]; - if (_tabController.index == 0) { - if (userTask.availableForUser) { - return _buildAvailableTaskCard(context, userTask); - } - } else if (_tabController.index == 1) { - if (userTask.state == UserTaskState.done || userTask.state == UserTaskState.expired) { - return _buildCompletedTaskCard(context, userTask); - } - } - return const SizedBox.shrink(); - }, childCount: widget.model.tasks.length), - ), - ], - ); - } - }, - ), + ), + ], + ); + }, ), - ], - ), + ), + ], ), ), ); } - Widget _buildParticipantDataCard() { - return GestureDetector( - child: StudiesMaterial( - hasBorder: true, - borderColor: taskTypeColors["ExpectedParticipantData"]!, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.horizontal(left: Radius.circular(2.0), right: Radius.circular(8.0)), + /// The Pending / Completed switch: a pill track with the selected segment + /// lifted out in white. + Widget _segmentedControl(CarpColors colors, RPLocalizations locale, int pending, int completed) => Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration(color: colors.grey200, borderRadius: BorderRadius.circular(12)), + child: TabBar( + controller: _tabController, + labelPadding: EdgeInsets.zero, + dividerColor: Colors.transparent, + indicatorSize: TabBarIndicatorSize.tab, + splashBorderRadius: BorderRadius.circular(8), + indicator: ShapeDecoration( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + color: colors.white, + shadows: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 6, offset: const Offset(0, 2))], ), - backgroundColor: Theme.of(context).extension()!.grey50!, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(width: 12.0), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - taskTypeIcons["ExpectedParticipantData"]!.icon, - color: taskTypeColors["ExpectedParticipantData"], - ), - Padding( - padding: const EdgeInsets.only(left: 4.0), - child: Text( - "Input Data", - style: TextStyle( - color: taskTypeColors["ExpectedParticipantData"], - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - const SizedBox(height: 8.0), - Padding( - padding: const EdgeInsets.all(4.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Participant Data", - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0), - ), - const SizedBox(height: 4.0), - Padding( - padding: const EdgeInsets.only(right: 20), - child: Text("Fill in the required participant data to continue with the study."), - ), - ], - ), - ), - ], - ), - ), - ], + tabs: [ + _tab(colors, locale.translate('pages.task_list.pending'), pending, 0), + _tab(colors, locale.translate('pages.task_list.completed'), completed, 1), + ], + ), + ), + ); + + /// One segment of the Pending / Completed switch, with a count badge. + Widget _tab(CarpColors colors, String label, int count, int index) { + final selected = _tabController.index == index; + return Tab( + height: 40, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: fs14fw600.copyWith(color: selected ? colors.grey900 : colors.grey600), ), ), - ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: selected ? colors.primary!.withValues(alpha: 0.12) : colors.grey300, + borderRadius: BorderRadius.circular(100), + ), + child: Text('$count', style: fs12fw600.copyWith(color: selected ? colors.primary : colors.grey600)), + ), + ], ), - onTap: () { - context.push(ParticipantDataPage.route); - }, ); } - Widget _buildAvailableTaskCard(BuildContext context, UserTask userTask) { - RPLocalizations locale = RPLocalizations.of(context)!; - - return Center( - child: GestureDetector( - child: StudiesMaterial( - hasBorder: true, - borderColor: taskTypeColors[userTask.type]!, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.horizontal(left: Radius.circular(2.0), right: Radius.circular(8.0)), - ), - backgroundColor: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 - ? CACHET.TASK_TO_EXPIRE_BACKGROUND - : Theme.of(context).extension()!.grey50!, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(width: 12.0), // Space between line and content - Expanded( - // Allows the content to take remaining space - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (userTask.state == UserTaskState.started) CircularProgressIndicator(), - if (userTask.state != UserTaskState.started) _taskTypeIcon(userTask), - Padding( - padding: const EdgeInsets.only(left: 4.0), - child: Text( - userTask.type[0].toUpperCase() + userTask.type.substring(1), - style: TextStyle(color: taskTypeColors[userTask.type], fontWeight: FontWeight.bold), - ), - ), - Spacer(), - if (_timeRemainingSubtitle(userTask).isNotEmpty) - Icon( - Icons.alarm, - color: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 - ? Theme.of(context).extension()!.warningColor - : Colors.grey, - ), - const SizedBox(width: 4.0), - Padding( - padding: const EdgeInsets.only(right: 20), - child: Text( - _timeRemainingSubtitle(userTask), - style: TextStyle( - color: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 - ? Theme.of(context).extension()!.warningColor - : Colors.grey, - fontSize: 12.0, - ), - ), - ), - ], - ), - const SizedBox(height: 8.0), - Padding( - padding: const EdgeInsets.all(4.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - locale.translate(userTask.title), - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0), - ), - const SizedBox(height: 4.0), - Padding( - padding: const EdgeInsets.only(right: 20), - child: Text(locale.translate(userTask.description)), - ), - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Padding( - padding: const EdgeInsets.only(right: 20), - child: Text( - _estimatedTimeSubtitle(userTask), - style: TextStyle(color: Colors.grey, fontSize: 12.0), - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ], + /// A task card, in either tab - the state decides the accent colour, the + /// badge and which meta chips are shown. + Widget _taskCard(BuildContext context, CarpColors colors, UserTask userTask) { + final locale = RPLocalizations.of(context)!; + final done = userTask.state == UserTaskState.done; + final expired = userTask.state == UserTaskState.expired; + final accent = done + ? CACHET.TASK_COMPLETED_BLUE + : expired + ? colors.grey500! + : taskTypeColors[userTask.type] ?? colors.primary!; + final description = locale.translate(userTask.description); + final (expiry, urgent) = _expiry(locale, userTask); + + return _card( + colors, + onTap: done || expired + ? null + : () { + if (widget.model.startUserTask(userTask)) context.push('/task/${userTask.id}'); + }, + badge: _badge(accent, child: _taskBadgeIcon(colors, userTask, accent)), + children: [ + Row( + children: [ + Expanded( + child: Text( + userTask.type[0].toUpperCase() + userTask.type.substring(1), + overflow: TextOverflow.ellipsis, + style: fs12fw600.copyWith(color: accent), ), ), + if (!done && !expired && expiry.isNotEmpty) + _chip(colors, Icons.alarm, expiry, urgent ? colors.warningColor! : colors.grey500!, filled: urgent), + if (done && userTask.doneTime != null) + Text( + DateFormat('MMM d, yyyy').format(userTask.doneTime!), + style: fs12fw600.copyWith(color: colors.grey500), + ), + ], + ), + const SizedBox(height: 4), + Text(locale.translate(userTask.title), style: fs16fw600.copyWith(color: colors.grey900)), + if (!done && !expired && description.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + description, + maxLines: 5, + overflow: TextOverflow.ellipsis, + style: fs14fw600.copyWith(color: colors.grey600), + ), + ], + if (!done && !expired) ...[ + const SizedBox(height: 10), + _chip(colors, Icons.schedule, _estimatedTime(locale, userTask), colors.grey500!), + ], + ], + ); + } + + Widget _participantDataCard(CarpColors colors) { + final accent = taskTypeColors["ExpectedParticipantData"]!; + return _card( + colors, + onTap: () => context.push(ParticipantDataPage.route), + badge: _badge(accent, child: Icon(taskTypeIcons["ExpectedParticipantData"]!.icon, color: accent, size: 20)), + children: [ + Text("Input Data", style: fs12fw600.copyWith(color: accent)), + const SizedBox(height: 4), + Text("Participant Data", style: fs16fw600.copyWith(color: colors.grey900)), + const SizedBox(height: 4), + Text( + "Fill in the required participant data to continue with the study.", + style: fs14fw600.copyWith(color: colors.grey600), + ), + ], + ); + } + + /// The shared card shell: badge on the left, content column, chevron when + /// the card leads somewhere. + Widget _card(CarpColors colors, {required Widget badge, required List children, VoidCallback? onTap}) { + return StudiesMaterial( + backgroundColor: colors.grey50!, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + badge, + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children)), + if (onTap != null) ...[ + const SizedBox(width: 8), + Icon(Icons.chevron_right, size: 20, color: colors.grey400), + ], + ], ), ), - onTap: () { - if (widget.model.startUserTask(userTask)) { - context.push('/task/${userTask.id}'); - } - }, ), ); } - /// Get an icon for the [userTask] based on its type. If there is no icon for - /// the type, use the 1st measure in the task as an icon. If there is no - /// icon for the measure, use a default icon. - Widget _taskTypeIcon(UserTask userTask) { - Icon originalIcon = taskTypeIcons[userTask.type] as Icon; + /// Rounded-square badge: the accent colour tinted, with the icon on top. + Widget _badge(Color accent, {required Widget child}) => Container( + width: 40, + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration(color: accent.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(12)), + child: child, + ); + + /// A small icon + label chip, outlined by default and tinted when [filled]. + Widget _chip(CarpColors colors, IconData icon, String label, Color color, {bool filled = false}) => Container( + padding: EdgeInsets.symmetric(horizontal: filled ? 8 : 0, vertical: filled ? 4 : 0), + decoration: filled + ? BoxDecoration(color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(100)) + : null, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Text(label, style: fs12fw600.copyWith(color: color)), + ], + ), + ); + + /// The icon for [userTask], or a spinner while it is running. + Widget _taskBadgeIcon(CarpColors colors, UserTask userTask, Color accent) { return StreamBuilder( stream: userTask.stateEvents, - initialData: UserTaskState.enqueued, + initialData: userTask.state, builder: (context, snapshot) { - if (taskTypeIcons[userTask.type] != null && userTask.availableForUser) { - return originalIcon; - } else if (taskTypeIcons[userTask.type] != null && userTask.state == UserTaskState.started) { - return Padding( - padding: const EdgeInsets.all(4), - child: SizedBox(child: CircularProgressIndicator(strokeWidth: 3), height: 14, width: 14), - ); - } else if (taskTypeIcons[userTask.type] != null && userTask.state == UserTaskState.done) { - return Icon(originalIcon.icon, color: CACHET.TASK_COMPLETED_BLUE); - } else { - return Icon(originalIcon.icon, color: Theme.of(context).extension()!.grey600); + if (userTask.state == UserTaskState.started) { + return SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2.5, color: accent)); } + final icon = taskTypeIcons[userTask.type]?.icon ?? Icons.assignment; + return Icon(userTask.state == UserTaskState.done ? Icons.check : icon, color: accent, size: 20); }, ); } - String _estimatedTimeSubtitle(UserTask userTask) { - RPLocalizations locale = RPLocalizations.of(context)!; - String subtitle = (userTask.task.minutesToComplete != null) - ? '${locale.translate('pages.task_list.task.estimated_time')} ${userTask.task.minutesToComplete} min' - : locale.translate('pages.task_list.task.auto_complete'); - - if (userTask.expiresIn != null) { - if (userTask.expiresIn!.isNegative) { - userTask.onExpired(); - } + /// How long [userTask] takes, or that it completes on its own. + String _estimatedTime(RPLocalizations locale, UserTask userTask) => userTask.task.minutesToComplete != null + ? '${locale.translate('pages.task_list.task.estimated_time')} ${userTask.task.minutesToComplete} min' + : locale.translate('pages.task_list.task.auto_complete'); + + /// The humanized time left on [userTask], and whether that is under a day. + (String, bool) _expiry(RPLocalizations locale, UserTask userTask) { + final expiresIn = userTask.expiresIn; + if (expiresIn == null) return ('', false); + if (expiresIn.isNegative) { + userTask.onExpired(); + return ('', false); } - - return subtitle.isEmpty ? locale.translate(userTask.description) : subtitle; + return (expiresIn.humanize(locale), expiresIn.inHours < 24); } - String _timeRemainingSubtitle(UserTask userTask) { - RPLocalizations locale = RPLocalizations.of(context)!; - String humanizedTimeRemaining = ""; - if (userTask.expiresIn != null) { - if (userTask.expiresIn!.isNegative) { - userTask.onExpired(); - } - - humanizedTimeRemaining = userTask.expiresIn!.humanize(locale); - } - - return humanizedTimeRemaining.isNotEmpty ? humanizedTimeRemaining : ""; - } - - Widget _buildCompletedTaskCard(BuildContext context, UserTask userTask) { - RPLocalizations locale = RPLocalizations.of(context)!; - return Center( - child: GestureDetector( - child: StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, - hasBorder: true, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.horizontal(left: Radius.circular(2.0), right: Radius.circular(8.0)), - ), - borderColor: (userTask.state == UserTaskState.done) ? CACHET.TASK_COMPLETED_BLUE : CACHET.GREY_6, - child: Padding( - padding: const EdgeInsets.only(top: 16, bottom: 16, right: 16), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(width: 12.0), // Space between line and content - Expanded( - // Allows the content to take remaining space - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _taskTypeIcon(userTask), - Padding( - padding: const EdgeInsets.only(left: 4.0), - child: Text( - userTask.type, - style: TextStyle( - color: (userTask.state == UserTaskState.done) - ? CACHET.TASK_COMPLETED_BLUE - : CACHET.GREY_6, - fontWeight: FontWeight.bold, - ), - ), - ), - Spacer(), - Text( - userTask.doneTime != null - ? DateFormat('MMMM dd yyyy').format(userTask.doneTime!) - : 'Done time null', - style: TextStyle( - color: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 - ? Theme.of(context).extension()!.warningColor - : Colors.grey, - fontSize: 12.0, - ), - ), - ], - ), - const SizedBox(height: 8.0), - Padding( - padding: const EdgeInsets.all(4.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - locale.translate(userTask.title), - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0), - ), - ], - ), - ), - ], - ), - ), - ], - ), + Widget _emptyState(CarpColors colors, RPLocalizations locale) { + return Padding( + padding: const EdgeInsets.fromLTRB(32, 48, 32, 48), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: colors.primary!.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + _tabController.index == 0 ? Icons.playlist_add_check : Icons.history, + color: colors.primary, + size: 28, ), ), - ), + const SizedBox(height: 16), + Text( + locale.translate("pages.task_list.no_tasks"), + style: fs14fw600.copyWith(color: colors.grey600), + textAlign: TextAlign.center, + ), + ], ), ); } - Widget _noTasks(BuildContext context) { - RPLocalizations locale = RPLocalizations.of(context)!; - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Ink( - width: 60, - height: 60, - decoration: const ShapeDecoration(color: CACHET.GREY_1, shape: CircleBorder()), - child: const Icon(Icons.playlist_add_check, color: Colors.white), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Text(locale.translate("pages.task_list.no_tasks"), style: fs16fw600, textAlign: TextAlign.center), - ), - ], - ); - } - static Map taskTypeIcons = { AppTask.SURVEY_TYPE: const Icon(Icons.workspaces, color: CACHET.TASK_BLUE), AppTask.COGNITIVE_ASSESSMENT_TYPE: const Icon(Icons.psychology, color: CACHET.LIGHT_PURPLE), diff --git a/lib/ui/widgets/carp_app_bar.dart b/lib/ui/widgets/carp_app_bar.dart index 38719d87..45a2673f 100644 --- a/lib/ui/widgets/carp_app_bar.dart +++ b/lib/ui/widgets/carp_app_bar.dart @@ -1,5 +1,40 @@ part of carp_study_app; +/// The page title shown under the [CarpAppBar] on every shell tab, so all +/// tabs share the same title font and padding. +class CarpPageTitle extends StatelessWidget { + final String title; + const CarpPageTitle(this.title, {super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Align( + alignment: Alignment.centerLeft, + child: Text(title, style: fs24fw700.copyWith(color: Theme.of(context).extension()!.grey900)), + ), + ); + } +} + +/// A section heading between the cards of a page, e.g. "TASKS". +class CarpSectionTitle extends StatelessWidget { + final String title; + const CarpSectionTitle(this.title, {super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text(title, style: fs18fw700.copyWith(color: Theme.of(context).extension()!.grey900)), + ), + ); + } +} + class CarpAppBar extends StatelessWidget { final bool hasProfileIcon; const CarpAppBar({super.key, this.hasProfileIcon = false}); @@ -22,9 +57,7 @@ class CarpAppBar extends StatelessWidget { IconButton( icon: Icon(Icons.account_circle, color: Theme.of(context).primaryColor, size: 30), tooltip: 'Profile', - onPressed: () { - Navigator.push(context, SlidePageRoute(ProfilePage(ProfilePageViewModel()))); - }, + onPressed: () => context.push(ProfilePage.route), ), ], ), diff --git a/lib/ui/widgets/studies_material.dart b/lib/ui/widgets/studies_material.dart index 322c2b35..987c5cc2 100644 --- a/lib/ui/widgets/studies_material.dart +++ b/lib/ui/widgets/studies_material.dart @@ -16,7 +16,7 @@ class StudiesMaterial extends StatelessWidget { super.key, required this.child, this.elevation = 0, - this.margin = const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + this.margin = const EdgeInsets.only(bottom: 16, left: 16, right: 16), this.shape = const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8))), this.clipBehavior, this.hasBorder = false, @@ -28,24 +28,33 @@ class StudiesMaterial extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.only(bottom: 16.0, left: 16.0, right: 16.0), - child: Material( - color: backgroundColor, - elevation: elevation, - shape: shape, - clipBehavior: clipBehavior ?? Clip.hardEdge, - child: Container( - decoration: hasBorder - ? BoxDecoration( - border: Border(left: BorderSide(color: borderColor, width: 4.0)), - ) - : hasBox - ? BoxDecoration( - border: Border.all(color: CACHET.LIGHT_2, width: 1.0), - borderRadius: BorderRadius.circular(16.0), - ) - : null, - child: child, + padding: margin, + child: Container( + // Soft "modern" shadow instead of Material elevation. + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 12, offset: const Offset(0, 4)), + ], + ), + child: Material( + color: backgroundColor, + elevation: elevation, + shape: shape, + clipBehavior: clipBehavior ?? Clip.hardEdge, + child: Container( + decoration: hasBorder + ? BoxDecoration( + border: Border(left: BorderSide(color: borderColor, width: 4.0)), + ) + : hasBox + ? BoxDecoration( + border: Border.all(color: CACHET.LIGHT_2, width: 1.0), + borderRadius: BorderRadius.circular(16.0), + ) + : null, + child: child, + ), ), ), ); diff --git a/lib/view_models/cards/activity_data_model.dart b/lib/view_models/cards/activity_data_model.dart index 7a1d732d..48b91eea 100644 --- a/lib/view_models/cards/activity_data_model.dart +++ b/lib/view_models/cards/activity_data_model.dart @@ -5,8 +5,44 @@ class ActivityCardViewModel extends SerializableViewModel { @override WeeklyActivities createModel() => WeeklyActivities(); - Map> get activities => model.activities; - List activitiesByType(ActivityType type) => model.activitiesByType(type); + + Map> get activities => (_hasActivity ? model : _demo).activities; + + List activitiesByType(ActivityType type) => + (activities[type] ?? const {}).entries.map((entry) => DailyActivity(entry.key, entry.value)).toList(); + + bool get _hasActivity => + !AppConfig.useDemoChartData || + model.activities.values.any((perDay) => perDay.values.any((minutes) => minutes > 0)); + + /// The demo week, folded together by the same code that folds live readings. + late final WeeklyActivities _demo = _aggregate(DemoChartData.activityMeasurements); + + static WeeklyActivities _aggregate(List measurements) { + final into = WeeklyActivities(); + Measurement? previous; + for (final measurement in measurements) { + previous = _addActivity(into, measurement, previous); + } + return into; + } + + /// Fold [measurement] into [into] and return it as the new previous reading. + /// + /// The probe reports the activity it currently sees, so a duration is the gap + /// until a *different* activity is reported. + static Measurement? _addActivity(WeeklyActivities into, Measurement measurement, Measurement? previous) { + if (previous == null) return measurement; + if ((measurement.data as Activity).type == (previous.data as Activity).type) return previous; + + final start = previous.dateTime; + into.increaseActivityDuration( + (previous.data as Activity).type, + start.weekday, + measurement.dateTime.difference(start).inMinutes, + ); + return measurement; + } /// Stream of activity measurements. Stream? get activityEvents => @@ -33,22 +69,8 @@ class ActivityCardViewModel extends SerializableViewModel { // listen for activity events and count the minutes activityEvents?.listen((measurement) { - var lastActivity = _lastActivity; - - if ((measurement.data as Activity).type != (lastActivity.data as Activity).type) { - // if we have a new type of activity - // add the minutes to the last known activity type - DateTime start = DateTime.fromMicrosecondsSinceEpoch(lastActivity.sensorStartTime); - DateTime end = DateTime.fromMicrosecondsSinceEpoch(measurement.sensorStartTime); - model.increaseActivityDuration( - (lastActivity.data as Activity).type, - start.weekday, - end.difference(start).inMinutes, - ); - // and then save the new activity - _lastActivity = measurement; - } - }); + _lastActivity = _addActivity(model, measurement, _lastActivity) ?? measurement; + }, onError: onMeasurementStreamError); } } diff --git a/lib/view_models/cards/demo_chart_data.dart b/lib/view_models/cards/demo_chart_data.dart new file mode 100644 index 00000000..58708608 --- /dev/null +++ b/lib/view_models/cards/demo_chart_data.dart @@ -0,0 +1,120 @@ +part of carp_study_app; + +/// Placeholder measurements for the statistics charts, shown while a study has +/// collected nothing yet and only when [AppConfig.useDemoChartData] is on. +/// +/// These are real CAMS [Measurement]s carrying the same [Data] types the +/// sampling packages emit - [StepCount], [PolarHR], [Activity] - each stamped +/// with the time it was "taken". The card view models aggregate them with the +/// very code that aggregates live measurements, so wiring up the real streams +/// changes where the data comes from and nothing about how it is read. +abstract final class DemoChartData { + /// Cumulative step readings across this week, as a pedometer reports them: + /// a running total that only ever grows. + static List get stepMeasurements { + const stepsPerDay = [5300, 10400, 5600, 400, 14200, 9800, 12300]; + var total = 0; + + return [ + for (final (index, steps) in stepsPerDay.indexed) ...[ + // One reading at the start of the day and one at the end, so the + // difference between them is that day's steps. + _measurement(StepCount(steps: total), _startOfWeek.add(Duration(days: index, hours: 6))), + _measurement(StepCount(steps: total += steps), _startOfWeek.add(Duration(days: index, hours: 22))), + ], + ]; + } + + /// A day of heart rate as a Polar strap reports it - one reading per minute, + /// quiet at night and higher around midday. + static List get heartRateMeasurements => [ + for (final (dayOffset, dayScale) in _dailyIntensity.indexed) + for (final (hour, (low, high)) in _hourlyBands.indexed) + for (final minute in const [10, 30, 50]) + _measurement( + PolarHR( + samples: [ + PolarHRSample( + hr: _hrAt(low, high, dayScale, minute), + rrsMs: const [], + contactStatus: true, + contactStatusSupported: true, + ), + ], + ), + _startOfWeek.add(Duration(days: dayOffset, hours: hour, minutes: minute)), + ), + ]; + + /// Activity transitions across this week. The recognition probe reports the + /// activity it sees; a duration is the gap until the next, different one. + static List get activityMeasurements => [ + for (final (dayOffset, day) in _dailyActivities.indexed) + for (final (type, startHour, endHour) in day) ...[ + _measurement( + Activity(type: type, confidence: 100), + _startOfWeek.add(Duration(days: dayOffset, hours: startHour)), + ), + _measurement( + Activity(type: ActivityType.STILL, confidence: 100), + _startOfWeek.add(Duration(days: dayOffset, hours: endHour)), + ), + ], + ]; + + static Measurement _measurement(Data data, DateTime at) => + Measurement(sensorStartTime: at.microsecondsSinceEpoch, data: data); + + /// Monday of the current week, at midnight. + static DateTime get _startOfWeek { + final today = DateTime.now(); + return DateTime(today.year, today.month, today.day).subtract(Duration(days: today.weekday - 1)); + } + + /// A heart rate inside [low]-[high], nudged by the day's intensity and + /// varied by the minute so the band is not a flat line. + static int _hrAt(double low, double high, double dayScale, int minute) => + (low + (high - low) * ((minute % 30) / 30) * dayScale).round(); + + /// How hard each weekday was, Monday first - scales that day's bands. + static const List _dailyIntensity = [0.8, 1.0, 0.7, 0.5, 1.0, 0.9, 0.75]; + + /// The resting-to-peak band for each hour of the day. + static const List<(double, double)> _hourlyBands = [ + (48, 58), + (46, 55), + (45, 54), + (45, 53), + (46, 56), + (50, 62), + (55, 72), + (62, 88), + (66, 95), + (64, 92), + (68, 99), + (70, 101), + (66, 94), + (63, 88), + (61, 84), + (65, 91), + (69, 97), + (72, 104), + (68, 96), + (63, 86), + (58, 78), + (54, 70), + (51, 64), + (49, 60), + ]; + + /// (activity, start hour, end hour) per weekday, Monday first. + static const List> _dailyActivities = [ + [(ActivityType.WALKING, 8, 9), (ActivityType.ON_BICYCLE, 17, 18)], + [(ActivityType.WALKING, 7, 8), (ActivityType.RUNNING, 18, 19), (ActivityType.ON_BICYCLE, 12, 13)], + [(ActivityType.WALKING, 8, 9), (ActivityType.ON_BICYCLE, 16, 17)], + [(ActivityType.WALKING, 12, 13)], + [(ActivityType.WALKING, 7, 8), (ActivityType.RUNNING, 17, 18), (ActivityType.ON_BICYCLE, 19, 20)], + [(ActivityType.WALKING, 10, 11), (ActivityType.ON_BICYCLE, 14, 15)], + [(ActivityType.WALKING, 9, 10), (ActivityType.RUNNING, 11, 12), (ActivityType.ON_BICYCLE, 15, 16)], + ]; +} diff --git a/lib/view_models/cards/heart_rate_data_model.dart b/lib/view_models/cards/heart_rate_data_model.dart index 48e935c0..127ca17b 100644 --- a/lib/view_models/cards/heart_rate_data_model.dart +++ b/lib/view_models/cards/heart_rate_data_model.dart @@ -6,26 +6,86 @@ class HeartRateCardViewModel extends SerializableViewModel { /// A map of heart rate values for each hour of the day. /// The key is the hour of the day (0-23) and the value is the min and max heart rate for that hour. - Map get hourlyHeartRate => model.hourlyHeartRate; + Map get hourlyHeartRate => (_hasHeartRate ? model : _demo).hourlyHeartRate; + + /// The min and max heart rate per weekday, Monday first. + Map get dailyHeartRate => (_hasHeartRate ? model : _demo).dailyHeartRate; + + /// The average heart rate across [bands], taking the middle of each measured + /// band. Null when none of them hold a value. + static double? averageOf(Iterable bands) { + final midpoints = bands + .where((band) => band.min != null && band.max != null) + .map((band) => (band.min! + band.max!) / 2); + if (midpoints.isEmpty) return null; + return midpoints.reduce((a, b) => a + b) / midpoints.length; + } + + /// The min and max across [bands], or nulls when none of them hold a value. + static HeartRateMinMaxPrHour rangeOf(Iterable bands) { + final measured = bands.where((band) => band.min != null && band.max != null); + if (measured.isEmpty) return HeartRateMinMaxPrHour(null, null); + return HeartRateMinMaxPrHour( + measured.map((band) => band.min!).reduce(min), + measured.map((band) => band.max!).reduce(max), + ); + } /// The current heart rate - double? get currentHeartRate => model.currentHeartRate; + double? get currentHeartRate => (_hasHeartRate ? model : _demo).currentHeartRate; + + HeartRateMinMaxPrHour get dayMinMax => rangeOf(hourlyHeartRate.values); + + bool get _hasHeartRate => !AppConfig.useDemoChartData || model.hourlyHeartRate.values.any((band) => band.max != null); + + /// The demo week, folded together by the same code that folds live readings. + late final HourlyHeartRate _demo = _aggregate(DemoChartData.heartRateMeasurements); + + static HourlyHeartRate _aggregate(List measurements) { + final into = HourlyHeartRate(); + final today = DateTime.now(); + for (final measurement in measurements) { + // Every reading counts towards its weekday; only today's fill the hours, + // which is what a live model holds - the hours reset at midnight. + final isToday = measurement.dateTime.day == today.day && measurement.dateTime.month == today.month; + _record(into, measurement, hourly: isToday); + if (isToday) into.currentHeartRate = bpmOf(measurement) ?? into.currentHeartRate; + } + return into; + } + + /// Fold [measurement] into [into], as a band for its weekday and - unless + /// [hourly] is off - for its hour of the day. + static void _record(HourlyHeartRate into, Measurement measurement, {bool hourly = true}) { + final bpm = bpmOf(measurement); + if (bpm == null || bpm <= 0) return; - HeartRateMinMaxPrHour get dayMinMax => HeartRateMinMaxPrHour(model.minHeartRate, model.maxHeartRate); + final at = measurement.dateTime; + into.addHeartRate(bpm, weekday: at.weekday, hour: hourly ? at.hour : null); + if (bpm > (into.maxHeartRate ?? 0)) into.maxHeartRate = bpm; + if (bpm < (into.minHeartRate ?? double.infinity)) into.minHeartRate = bpm; + } + + /// The beats per minute in [measurement], whichever sensor reported it. + static double? bpmOf(Measurement measurement) => switch (measurement.data) { + PolarHR data => data.samples.firstOrNull?.hr.toDouble(), + MovesenseHR data => data.hr, + _ => null, + }; - final StreamGroup _group = StreamGroup.broadcast(); + final StreamGroup _group = StreamGroup.broadcast(); - Stream? get heartRateStream => _group.stream.asBroadcastStream(); + /// Stream of heart rate readings in BPM, for the card to rebuild on. + Stream? get heartRateStream => + _group.stream.map(bpmOf).where((bpm) => bpm != null).cast().asBroadcastStream(); /// Stream of heart rate based on [PolarHR] measures. - Stream? get polarHRStream => controller?.measurements - .where((measurement) => measurement.data is PolarHR) - .map((measurement) => (measurement.data as PolarHR).samples.firstOrNull?.hr.toDouble() ?? 0); + Stream? get polarHRStream => + controller?.measurements.where((measurement) => measurement.data is PolarHR); /// Stream of heart rate based on [MovesenseHR] measures. - Stream? get movesenseHRStream => controller?.measurements - .where((measurement) => measurement.data is MovesenseHR) - .map((measurement) => (measurement.data as MovesenseHR).hr); + Stream? get movesenseHRStream => + controller?.measurements.where((measurement) => measurement.data is MovesenseHR); @override void init(SmartphoneStudyController ctrl) { @@ -34,16 +94,12 @@ class HeartRateCardViewModel extends SerializableViewModel { if (polarHRStream != null) _group.add(polarHRStream!); if (movesenseHRStream != null) _group.add(movesenseHRStream!); - heartRateStream?.listen((hr) { - if (!(hr > 0)) { - model.currentHeartRate = null; - return; - } - model.addHeartRate(DateTime.now().hour, hr); - if (hr > (model.maxHeartRate ?? 0)) model.maxHeartRate = hr; - if (hr < (model.minHeartRate ?? 100000)) model.minHeartRate = hr; + _group.stream.listen((measurement) { + final bpm = bpmOf(measurement); + model.currentHeartRate = bpm != null && bpm > 0 ? bpm : null; + _record(model, measurement); model.resetDataAtMidnight(); - }); + }, onError: onMeasurementStreamError); } } @@ -57,10 +113,20 @@ class HourlyHeartRate extends DataModel { /// The min and max heart rate is expressed as a [HeartRateMinMaxPrHour] object. Map hourlyHeartRate = {}; + /// The min and max heart rate per weekday, for the week view. + /// + /// Keyed by [DateTime.weekday], so Monday is 1. + // ponytail: no weekly rollover - a study running past a week keeps widening + // each day's band. Add one when studies actually run that long. + Map dailyHeartRate = {}; + HourlyHeartRate() { for (int i = 0; i < 24; i++) { hourlyHeartRate[i] = HeartRateMinMaxPrHour(null, null); } + for (int i = 1; i <= 7; i++) { + dailyHeartRate[i] = HeartRateMinMaxPrHour(null, null); + } } /// The last updated time of the heart rate. @@ -81,6 +147,7 @@ class HourlyHeartRate extends DataModel { for (int i = 0; i < 24; i++) { hourlyHeartRate[i] = HeartRateMinMaxPrHour(null, null); } + // The day bands survive - they are what the week view is made of. maxHeartRate = null; minHeartRate = null; } @@ -88,34 +155,29 @@ class HourlyHeartRate extends DataModel { return this; } - /// Add a heart rate value for a given hour. - /// If the hour already exists, the min and max values are updated. - /// If the hour does not exist, it is added. - HourlyHeartRate addHeartRate(int hour, double heartRate) { - if (hour < 0 || hour > 23) { + /// Widen the bands [heartRate] belongs to: its [weekday], and its [hour] of + /// the day unless that is null. + HourlyHeartRate addHeartRate(double heartRate, {required int weekday, int? hour}) { + if (hour != null && (hour < 0 || hour > 23)) { throw AssertionError("hour must be in the range 0 to 23"); } - - currentHeartRate = heartRate; - if (hourlyHeartRate.containsKey(hour)) { - hourlyHeartRate.update(hour, (value) { - double? minVal = value.min, maxVal = value.max; - if (minVal != null && maxVal != null) { - return value - ..min = min(minVal, heartRate) - ..max = max(maxVal, heartRate); - } else { - return value - ..min = heartRate - ..max = heartRate; - } - }); - } else { - hourlyHeartRate[hour] = HeartRateMinMaxPrHour(heartRate, heartRate); + if (weekday < 1 || weekday > 7) { + throw AssertionError("weekday must be in the range 1 to 7"); } + + if (hour != null) hourlyHeartRate[hour] = _widen(hourlyHeartRate[hour], heartRate); + dailyHeartRate[weekday] = _widen(dailyHeartRate[weekday], heartRate); return this; } + /// [band] grown to include [heartRate], or a new band around it. + static HeartRateMinMaxPrHour _widen(HeartRateMinMaxPrHour? band, double heartRate) { + if (band?.min == null || band?.max == null) return HeartRateMinMaxPrHour(heartRate, heartRate); + return band! + ..min = min(band.min!, heartRate) + ..max = max(band.max!, heartRate); + } + @override String toString() { String str = 'time | heart rate\n'; diff --git a/lib/view_models/cards/measurements_data_model.dart b/lib/view_models/cards/measurements_data_model.dart index d74a54d1..c903dad2 100644 --- a/lib/view_models/cards/measurements_data_model.dart +++ b/lib/view_models/cards/measurements_data_model.dart @@ -19,7 +19,7 @@ class MeasurementsCardViewModel extends ViewModel { String key = measurement.dataType.name; if (!_samplingTable.containsKey(key)) _samplingTable[key] = 0; _samplingTable[key] = _samplingTable[key]! + 1; - }); + }, onError: onMeasurementStreamError); return _samplingTable; } diff --git a/lib/view_models/cards/mobility_data_model.dart b/lib/view_models/cards/mobility_data_model.dart index f93795dc..c3f6f7ae 100644 --- a/lib/view_models/cards/mobility_data_model.dart +++ b/lib/view_models/cards/mobility_data_model.dart @@ -34,7 +34,7 @@ class MobilityCardViewModel extends SerializableViewModel { mobilityEvents?.listen((measurement) { Mobility mobility = measurement.data as Mobility; model.setMobilityFeatures(mobility); - }); + }, onError: onMeasurementStreamError); } } diff --git a/lib/view_models/cards/steps_data_model.dart b/lib/view_models/cards/steps_data_model.dart index e23c6cbe..1d1bee87 100644 --- a/lib/view_models/cards/steps_data_model.dart +++ b/lib/view_models/cards/steps_data_model.dart @@ -7,10 +7,36 @@ class StepsCardViewModel extends SerializableViewModel { WeeklySteps createModel() => WeeklySteps(); /// A map of weekly steps organized by the day of the week. - Map get weeklySteps => model.weeklySteps; + Map get weeklySteps => (_hasSteps ? model : _demo).weeklySteps; /// The list of steps. - List get steps => model.steps; + List get steps => weeklySteps.entries.map((entry) => DailySteps(entry.key, entry.value)).toList(); + + bool get _hasSteps => !AppConfig.useDemoChartData || model.weeklySteps.values.any((steps) => steps > 0); + + /// The demo week, folded together by the same code that folds live readings. + late final WeeklySteps _demo = _aggregate(DemoChartData.stepMeasurements); + + static WeeklySteps _aggregate(List measurements) { + final into = WeeklySteps(); + StepCount? previous; + for (final measurement in measurements) { + previous = _addStepCount(into, measurement, previous); + } + return into; + } + + /// Fold [measurement] into [into] and return it as the new previous reading. + /// + /// A pedometer reports a running total, so a day's steps are the growth since + /// the last reading - the first reading only establishes a baseline. + static StepCount? _addStepCount(WeeklySteps into, Measurement measurement, StepCount? previous) { + final step = measurement.data as StepCount; + if (previous != null) { + into.increaseStepCount(measurement.dateTime.weekday, step.steps - previous.steps); + } + return step; + } final DateTime _startOfWeek = DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); final DateTime _endOfWeek = DateTime.now() @@ -36,14 +62,9 @@ class StepsCardViewModel extends SerializableViewModel { super.init(ctrl); // listen for pedometer events and count them - pedometerEvents?.listen((pedometerDataPoint) { - StepCount? step = pedometerDataPoint.data as StepCount?; - if (_lastStep != null) { - model.increaseStepCount(DateTime.now().weekday, step!.steps - _lastStep!.steps); - } - - _lastStep = step; - }); + pedometerEvents?.listen((measurement) { + _lastStep = _addStepCount(model, measurement, _lastStep); + }, onError: onMeasurementStreamError); } } diff --git a/lib/view_models/cards/study_progress_data_model.dart b/lib/view_models/cards/study_progress_data_model.dart index 788e2f34..54732b29 100644 --- a/lib/view_models/cards/study_progress_data_model.dart +++ b/lib/view_models/cards/study_progress_data_model.dart @@ -1,9 +1,38 @@ part of carp_study_app; -/// View model for the [StudyProgressCardWidget]. +/// View model for the [StudyProgressCardWidget] and [TaskCompletionCard]. class StudyProgressCardViewModel extends ViewModel { + /// How many days of task history the [TaskCompletionCard] shows. + static const int completionHistoryDays = 14; + final Map _progressTable = {}; + /// The number of tasks completed on each of the last [completionHistoryDays] + /// days, oldest first - a zero for days with nothing done. + List get recentCompletions => completionsPerDay( + AppTaskController().userTaskQueue.where((task) => task.state == UserTaskState.done).map((task) => task.doneTime), + ); + + /// Bucket [doneTimes] into one count per day for the [completionHistoryDays] + /// days ending on [today], oldest first. Null times and anything older than + /// the window are ignored. + static List completionsPerDay(Iterable doneTimes, {DateTime? today}) { + final done = {}; + for (final time in doneTimes) { + if (time == null) continue; + final day = _dayKey(time.toLocal()); + done[day] = (done[day] ?? 0) + 1; + } + + final lastDay = today ?? DateTime.now(); + return List.generate( + completionHistoryDays, + (index) => done[_dayKey(lastDay.subtract(Duration(days: completionHistoryDays - 1 - index)))] ?? 0, + ); + } + + static String _dayKey(DateTime date) => '${date.year}-${date.month}-${date.day}'; + /// A stream of [UserTask]s as they are generated. Stream get userTaskEvents => AppTaskController().userTaskEvents; diff --git a/lib/view_models/device_view_models.dart b/lib/view_models/device_view_model.dart similarity index 87% rename from lib/view_models/device_view_models.dart rename to lib/view_models/device_view_model.dart index 1370d1f2..72573d7a 100644 --- a/lib/view_models/device_view_models.dart +++ b/lib/view_models/device_view_model.dart @@ -1,6 +1,11 @@ part of carp_study_app; -/// The view model for the [DeviceListPage]. +/// View model for [DeviceListPage]. +/// +/// State: the devices and online services of the deployment, as +/// [DeviceViewModel]s. +/// +/// Connecting and disconnecting is owned by each [DeviceViewModel]. class DeviceListPageViewModel extends ViewModel { DeviceListPageViewModel({StudyService? studyService}) : _studyService = studyService; @@ -27,14 +32,43 @@ class DeviceListPageViewModel extends ViewModel { onlineServices.where((device) => device.type == HealthService.DEVICE_TYPE).firstOrNull; } -/// The view model for each device - [DeviceManager]. +/// View model for one device row on [DeviceListPage]. +/// +/// State: the name, icon, and live connection status of its [DeviceManager], +/// which can be either a hardware device or an online service. /// -/// Note that the [deviceManager] can represent both a hardware device and -/// an online service. +/// Connects and disconnects that device. class DeviceViewModel extends ViewModel { DeviceManager deviceManager; DeviceViewModel(this.deviceManager) : super(); + StreamSubscription? _statusSub; + + // Bridge the device manager's status stream into ChangeNotifier + // notifications, so widgets listening to this view model rebuild when the + // device connects or disconnects. Subscribed on first listener and cancelled + // on the last, so the ephemeral instances from `deploymentDevices` don't leak. + @override + void addListener(VoidCallback listener) { + _statusSub ??= deviceManager.statusEvents.listen((_) => notifyListeners()); + super.addListener(listener); + } + + @override + void removeListener(VoidCallback listener) { + super.removeListener(listener); + if (!hasListeners) { + _statusSub?.cancel(); + _statusSub = null; + } + } + + @override + void dispose() { + _statusSub?.cancel(); + super.dispose(); + } + /// The type of this device. String? get type => deviceManager.deviceType; diff --git a/lib/view_models/home_page_model.dart b/lib/view_models/home_page_model.dart deleted file mode 100644 index 3b3ab22c..00000000 --- a/lib/view_models/home_page_model.dart +++ /dev/null @@ -1,36 +0,0 @@ -part of carp_study_app; - -/// The view model for the [HomePage]. -class HomePageViewModel extends ViewModel { - HomePageViewModel({SystemInfoService? systemInfoService}) : _systemInfoService = systemInfoService; - - final SystemInfoService? _systemInfoService; - SystemInfoService get _system => _systemInfoService ?? bloc.system; - - bool _healthConnectPromptPending = false; - - /// Should the user be prompted to install Health Connect? - /// One-shot - the page calls [healthConnectPromptShown] once shown. - bool get shouldPromptHealthConnectInstall => _healthConnectPromptPending; - - void healthConnectPromptShown() => _healthConnectPromptPending = false; - - @override - void init(SmartphoneStudyController ctrl) { - super.init(ctrl); - unawaited(_checkHealthConnectInstallation()); - } - - Future _checkHealthConnectInstallation() async { - if (!Platform.isAndroid) return; - if (await _system.isHealthInstalled()) return; - _healthConnectPromptPending = true; - notifyListeners(); - } - - @override - void clear() { - _healthConnectPromptPending = false; - super.clear(); - } -} diff --git a/lib/view_models/home_view_model.dart b/lib/view_models/home_view_model.dart new file mode 100644 index 00000000..9180cbbb --- /dev/null +++ b/lib/view_models/home_view_model.dart @@ -0,0 +1,187 @@ +part of carp_study_app; + +/// The 3-state connection summary shown on the home page. +enum HomeConnectionState { all, partial, none } + +/// View model for [HomePage] and [CarpAppShell]. +/// +/// State: whether the study is loaded, its title and status, connected devices, +/// task and active-day counts, announcements, and the app update / Health +/// Connect prompts. +/// +/// Keeps that data fresh from the services and streams so the pages only read +/// and rebuild. +class HomePageViewModel extends ViewModel { + HomePageViewModel({SystemInfoService? systemInfoService, StudyService? studyService, MessageService? messageService}) + : _systemInfoService = systemInfoService, + _studyService = studyService, + _messageService = messageService; + + final SystemInfoService? _systemInfoService; + final StudyService? _studyService; + final MessageService? _messageService; + SystemInfoService get _system => _systemInfoService ?? bloc.system; + StudyService get _study => _studyService ?? bloc.study; + MessageService get _messages => _messageService ?? bloc.messages; + + bool _healthConnectPromptPending = false; + bool _appUpdateAvailable = false; + List _connectionSources = const []; + final List> _deviceSubs = []; + StreamSubscription? _userTaskSub; + StreamSubscription? _messageSub; + bool _blocAttached = false; + + /// The announcements/news shown in the "Feeds" section, newest first. + List get messages => _messages.messages; + + /// Is the study (and thus the home page content) loaded? Until then the + /// page shows a shimmer skeleton. Waits for both the study description and + /// the deployment status, so the about card (incl. its status bubble) + /// renders complete in one go instead of growing when the status lands. + bool get isLoaded => + (_study.deployment?.studyDescription?.title ?? '').isNotEmpty && _study.cachedDeploymentStatus != null; + + /// The title and description of this study, shown on the home about card. + String get studyTitle => _study.deployment?.studyDescription?.title ?? ''; + String get studyDescription => _study.deployment?.studyDescription?.description ?? ''; + + // The distinct calendar days on which the user completed at least one task. + Set get _activeDays => AppTaskController().userTaskQueue + .where((task) => task.state == UserTaskState.done && task.doneTime != null) + .map((task) => _dayKey(task.doneTime!.toLocal())) + .toSet(); + + static String _dayKey(DateTime d) => '${d.year}-${d.month}-${d.day}'; + + /// The number of days on which the user completed at least one task. + int get activeDaysInStudy => _activeDays.length; + + /// Task activity for the last 7 days (oldest first) - true if at least one + /// task was completed that day. + List get lastWeekActivity { + final done = _activeDays; + final today = DateTime.now(); + return List.generate(7, (i) => done.contains(_dayKey(today.subtract(Duration(days: 6 - i))))); + } + + /// The number of tasks completed so far. + int get taskCompleted => AppTaskController().userTaskQueue.where((task) => task.state == UserTaskState.done).length; + + /// The number of tasks still available for the user to do. + int get taskPending => AppTaskController().userTaskQueue.where((task) => task.availableForUser).length; + + /// The deployment status of this study, or null if not deployed yet. + StudyDeploymentStatusTypes? get deploymentStatus => _study.cachedDeploymentStatus == null + ? null + : _study.cachedDeploymentStatus!.status ?? StudyDeploymentStatusTypes.Invited; + + /// Should the user be prompted to install Health Connect? + /// One-shot - the shell calls [healthConnectPromptShown] once shown. + bool get shouldPromptHealthConnectInstall => _healthConnectPromptPending; + + void healthConnectPromptShown() => _healthConnectPromptPending = false; + + /// Is a newer version of this app available? Checked once on [init]. + bool get appUpdateAvailable => _appUpdateAvailable; + + /// Open this app's store listing. + Future openAppStore() => _system.openAppStore(); + + /// The connectable data sources of this deployment (everything but the phone). + List get connectionSources => _connectionSources; + bool isSourceActive(DeviceViewModel d) => d.status == DeviceStatus.connected; + int get totalSourceCount => _connectionSources.length; + int get activeSourceCount => _connectionSources.where(isSourceActive).length; + + HomeConnectionState get connectionState { + final active = activeSourceCount; + if (active == 0) return HomeConnectionState.none; + return active >= totalSourceCount ? HomeConnectionState.all : HomeConnectionState.partial; + } + + @override + void init(SmartphoneStudyController ctrl) { + super.init(ctrl); + _attachToBloc(); + _syncSources(); + _userTaskSub = AppTaskController().userTaskEvents.listen((_) => notifyListeners()); + _messageSub = _messages.stream.listen((_) => notifyListeners()); + unawaited(_checkHealthConnectInstallation()); + unawaited(_checkAppUpdate()); + unawaited(_refreshDeploymentStatus()); + } + + // The cached status is only filled by an explicit refresh; without this the + // status card stays hidden. + Future _refreshDeploymentStatus() async { + try { + await _study.refreshDeploymentStatus(); + } catch (error) { + warning('$runtimeType - could not refresh deployment status - $error'); + } + notifyListeners(); + } + + // Re-read the device list whenever the bloc notifies (e.g. configuration + // completing). Device managers register asynchronously, so the initial sync + // in init() is usually empty; this is what fills the summary in later. + void _attachToBloc() { + if (_blocAttached) return; + _blocAttached = true; + bloc.addListener(_syncSources); + } + + void _syncSources() { + final sources = _study.deploymentDevices.where((d) => d.deviceManager is! SmartphoneDeviceManager).toList(); + _cancelDeviceSubs(); + // Subscribe to the durable device-manager stream directly, so the ephemeral + // DeviceViewModel wrappers from deploymentDevices don't matter. + for (final s in sources) { + _deviceSubs.add(s.statusEvents.listen((_) => notifyListeners())); + } + _connectionSources = sources; + notifyListeners(); + } + + void _cancelDeviceSubs() { + for (final s in _deviceSubs) { + s.cancel(); + } + _deviceSubs.clear(); + } + + Future _checkAppUpdate() async { + try { + _appUpdateAvailable = await _system.getAppHasUpdate() ?? false; + } catch (_) { + _appUpdateAvailable = false; + } + notifyListeners(); + } + + Future _checkHealthConnectInstallation() async { + if (!Platform.isAndroid) return; + if (await _system.isHealthInstalled()) return; + _healthConnectPromptPending = true; + notifyListeners(); + } + + @override + void clear() { + _cancelDeviceSubs(); + _connectionSources = const []; + _healthConnectPromptPending = false; + _appUpdateAvailable = false; + super.clear(); + } + + @override + void dispose() { + if (_blocAttached) bloc.removeListener(_syncSources); + _cancelDeviceSubs(); + _userTaskSub?.cancel(); + _messageSub?.cancel(); + super.dispose(); + } +} diff --git a/lib/view_models/informed_consent_page_model.dart b/lib/view_models/informed_consent_page_model.dart deleted file mode 100644 index d36368c0..00000000 --- a/lib/view_models/informed_consent_page_model.dart +++ /dev/null @@ -1,35 +0,0 @@ -part of carp_study_app; - -class InformedConsentViewModel extends ViewModel { - RPOrderedTask? _informedConsent; - - InformedConsentViewModel(); - - @override - void clear() { - _informedConsent = null; - super.clear(); - } - - /// Get the informed consent for this study as translated to the - /// local [locale]. - Future getInformedConsent(Locale locale) async { - if (_informedConsent == null) { - await bloc.resources.localizationLoader.load(locale); - _informedConsent = await bloc.consent.getDocument(); - } - return _informedConsent; - } - - /// Called when the informed consent has been accepted by the user. - /// Returns once the upload to the backend has completed, so callers can - /// safely route to a page whose redirect re-queries the backend. - Future informedConsentHasBeenAccepted(RPTaskResult informedConsentResult) => - bloc.consent.accept(informedConsentResult); - - /// Accept consent for a study that has no consent document. - Future acceptWithoutDocument() => bloc.consent.accept(); - - /// The user abandoned the consent flow - leave the study. - void abandonConsent() => bloc.leaveStudy(); -} diff --git a/lib/view_models/informed_consent_view_model.dart b/lib/view_models/informed_consent_view_model.dart new file mode 100644 index 00000000..8d65f12b --- /dev/null +++ b/lib/view_models/informed_consent_view_model.dart @@ -0,0 +1,80 @@ +part of carp_study_app; + +/// Whether the user still has to sign informed consent for the active study. +enum ConsentStatus { + /// Consent is in place - already given, or the study has no document to sign. + given, + + /// The user has to be shown the [InformedConsentPage] and sign. + needsSigning, +} + +/// View model for [InformedConsentPage], gated by [CarpAppShell]. +/// +/// State: the consent document of the active study, once loaded. +/// +/// Answers whether the user still has to sign, and records their accept or +/// reject - locally, and in CAWS when there was a document to sign. +class InformedConsentViewModel extends ViewModel { + RPOrderedTask? _informedConsent; + + InformedConsentViewModel(); + + /// The consent document, once loaded by [getInformedConsent]. + RPOrderedTask? get informedConsent => _informedConsent; + + @override + void clear() { + _informedConsent = null; + super.clear(); + } + + /// The consent document of the active study, or null if it has none. + Future getInformedConsent() async => _informedConsent ??= await bloc.consent.getDocument(); + + /// Whether the user still has to sign informed consent. + /// + /// A study with no consent document has nothing to sign and is accepted on + /// the user's behalf, so callers only ever have to act on [needsSigning]. + Future hasBeenAccepted() async { + if (await _isAccepted) return ConsentStatus.given; + + if (await getInformedConsent() == null) { + await accept(); + return ConsentStatus.given; + } + return ConsentStatus.needsSigning; + } + + /// Record that the user accepted, uploading the signed [result] when there + /// was a document to sign. + Future accept([RPTaskResult? result]) async { + info('Informed consent has been accepted by user.'); + _acceptedLocally = true; + if (result != null && !_isLocalDeployment) await bloc.consent.upload(result); + } + + /// Record that the user declined, and leave the study - without consent there + /// is nothing to participate in. + Future reject() async { + info('Informed consent has been declined by user.'); + _acceptedLocally = false; + await bloc.leaveStudy(); + } + + // Consent belongs to the account rather than the phone, so a CAWS deployment + // asks the backend - that also covers consent given on another device. A + // local deployment has no backend and only has the flag on this phone. + Future get _isAccepted async => + _isLocalDeployment ? _acceptedLocally : await bloc.consent.hasSignedConsent(bloc.study.study); + + bool get _isLocalDeployment => AppConfig.deploymentMode == DeploymentMode.local; + + bool get _acceptedLocally => LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false; + + set _acceptedLocally(bool accepted) { + final participant = LocalSettings().participant; + participant?.hasInformedConsentBeenAccepted = accepted; + LocalSettings().participant = participant; + } +} diff --git a/lib/view_models/invitations_view_model.dart b/lib/view_models/invitations_view_model.dart index a314d99c..2980af75 100644 --- a/lib/view_models/invitations_view_model.dart +++ b/lib/view_models/invitations_view_model.dart @@ -1,6 +1,11 @@ part of carp_study_app; -/// The view model for the [InvitationListPage] and [InvitationDetailsPage]. +/// View model for [InvitationListPage] and [InvitationDetailsPage]. +/// +/// State: the invitations fetched from CAWS, and whether that fetch is pending +/// or failed. +/// +/// Accepts an invitation as the app's active study, and signs the user out. class InvitationsViewModel extends ViewModel { InvitationsViewModel({AuthService? authService}) : _authService = authService; @@ -24,23 +29,17 @@ class InvitationsViewModel extends ViewModel { /// The route to land on after authenticating: the single invitation's /// detail if there is exactly one, otherwise the list. String get landingRoute => invitations.length == 1 - ? '${InvitationDetailsPage.route}/${invitations.first.participation.participantId}' + ? '${InvitationDetailsPage.route}/${invitations.first.studyDeploymentId}' : InvitationListPage.route; /// Load / refresh the list of invitations from CAWS. Always hits the /// backend - used for sign-in and pull-to-refresh. Future loadInvitations() async { - logApp('InvitationsViewModel.loadInvitations() START'); try { _invitations = await _auth.getInvitations(); _error = null; - logApp( - 'InvitationsViewModel.loadInvitations() DONE - ${_invitations!.length} invitation(s): ' - '${_invitations!.map((i) => i.studyDeploymentId).toList()}', - ); } catch (error) { warning('$runtimeType - Could not load invitations - $error'); - logApp('InvitationsViewModel.loadInvitations() FAILED - $error'); _error = error; } notifyListeners(); @@ -50,16 +49,19 @@ class InvitationsViewModel extends ViewModel { /// page to avoid re-fetching when sign-in just loaded them; pull-to-refresh /// calls [loadInvitations] directly to force a refresh. Future ensureInvitationsLoaded() async { - if (isLoaded) return; + if (isLoaded) { + return; + } await loadInvitations(); } - ActiveParticipationInvitation getInvitation(String invitationId) => - invitations.firstWhere((invitation) => invitation.participation.participantId == invitationId); + /// The invitation identified by its study deployment id - the only id that + /// is unique per invitation. Returns null if not found. + ActiveParticipationInvitation? getInvitation(String deploymentId) => + invitations.where((invitation) => invitation.studyDeploymentId == deploymentId).firstOrNull; /// Accept [invitation] and make it the active study in the app. void accept(ActiveParticipationInvitation invitation) { - logApp('InvitationsViewModel.accept() - deploymentId=${invitation.studyDeploymentId}'); bloc.setStudyInvitation(invitation); } diff --git a/lib/view_models/login_page_model.dart b/lib/view_models/login_view_model.dart similarity index 87% rename from lib/view_models/login_page_model.dart rename to lib/view_models/login_view_model.dart index 37d1b812..a7822520 100644 --- a/lib/view_models/login_page_model.dart +++ b/lib/view_models/login_view_model.dart @@ -3,8 +3,11 @@ part of carp_study_app; /// The outcome of a sign-in attempt. enum SignInResult { success, offline, failed } -/// The view model for the [LoginPage] and [QRViewExample] - owns the -/// authentication flow. +/// View model for [LoginPage] and [QRViewExample]. +/// +/// State: whether the user is authenticated. +/// +/// Runs the sign-in flow - CAWS web view, magic link, and sign-out. class LoginViewModel extends ViewModel { LoginViewModel({AuthService? authService, SystemInfoService? systemInfoService}) : _authService = authService, @@ -20,7 +23,6 @@ class LoginViewModel extends ViewModel { /// Sign in via the CAWS web view. Future signIn() async { - logAppState('LoginViewModel.signIn() START'); if (!await _system.checkConnectivity()) return SignInResult.offline; await _auth.initialize(); @@ -28,7 +30,6 @@ class LoginViewModel extends ViewModel { notifyListeners(); final result = _auth.isAuthenticated ? SignInResult.success : SignInResult.failed; - logAppState('LoginViewModel.signIn() DONE - result=${result.name}'); return result; } diff --git a/lib/view_models/participant_data_page_model.dart b/lib/view_models/participant_data_view_model.dart similarity index 92% rename from lib/view_models/participant_data_page_model.dart rename to lib/view_models/participant_data_view_model.dart index 5f0c0a83..5df80f54 100644 --- a/lib/view_models/participant_data_page_model.dart +++ b/lib/view_models/participant_data_view_model.dart @@ -1,5 +1,10 @@ part of carp_study_app; +/// View model for [ParticipantDataPage]. +/// +/// State: the participant data the study expects the user to fill in. +/// +/// Writes the filled-in data back to the deployment. class ParticipantDataPageViewModel extends ViewModel { Set get expectedData => bloc.study.expectedParticipantData; diff --git a/lib/view_models/profile_page_model.dart b/lib/view_models/profile_view_model.dart similarity index 70% rename from lib/view_models/profile_page_model.dart rename to lib/view_models/profile_view_model.dart index ee9e9706..da123cb2 100644 --- a/lib/view_models/profile_page_model.dart +++ b/lib/view_models/profile_view_model.dart @@ -1,5 +1,11 @@ part of carp_study_app; +/// View model for [ProfilePage]. +/// +/// State: the signed-in user's name and id, and the active study's ids, roles +/// and server. +/// +/// Handles leaving the study, signing out, and downloading the signed consent. class ProfilePageViewModel extends ViewModel { ProfilePageViewModel({AuthService? authService, StudyService? studyService, SystemInfoService? systemInfoService}) : _authService = authService, @@ -33,7 +39,7 @@ class ProfilePageViewModel extends ViewModel { String get fullName => '$firstName $lastName'; String get email => _auth.user?.email ?? ''; - String get studyId => _study.deployment?.studyId ?? ''; + String get studyId => _study.study?.studyId ?? ''; String get studyDeploymentId => _study.deployment?.studyDeploymentId ?? ''; String get studyDeploymentTitle => _study.deployment?.studyDescription?.title ?? ''; String get participantId => _study.study?.participantId ?? ''; @@ -46,4 +52,18 @@ class ProfilePageViewModel extends ViewModel { String get studyDescriptionUrl => _study.deployment?.studyDescription?.studyDescriptionUrl ?? ''; String get deviceID => DeviceInfoService().deviceID ?? ''; String get currentServer => _auth.serverUri.toString(); + + /// Fetch the signed informed consent from the backend and save it as a JSON + /// file. Returns null when no signed consent exists (e.g. local deployments). + Future downloadInformedConsent() async { + try { + final consent = await CarpBackend().getInformedConsentByRole(studyDeploymentId, participantRole); + if (consent == null) return null; + final dir = await getApplicationDocumentsDirectory(); + return File('${dir.path}/informed_consent.json').writeAsString(toJsonString(consent.toJson())); + } catch (e) { + warning('$runtimeType - could not download informed consent - $e'); + return null; + } + } } diff --git a/lib/view_models/data_visualization_page_model.dart b/lib/view_models/statistics_view_model.dart similarity index 91% rename from lib/view_models/data_visualization_page_model.dart rename to lib/view_models/statistics_view_model.dart index 7229918a..f17c0df7 100644 --- a/lib/view_models/data_visualization_page_model.dart +++ b/lib/view_models/statistics_view_model.dart @@ -1,7 +1,14 @@ part of carp_study_app; -class DataVisualizationPageViewModel extends ViewModel { - DataVisualizationPageViewModel({StudyService? studyService}) : _studyService = studyService; +/// View model for [StatisticsPage]. +/// +/// State: which measures the deployment collects, so only the cards backed by +/// real data are shown, plus one view model per card. +/// +/// Turning raw CAMS measurements into what the charts draw happens here, in +/// the card view models below - the page only lays the results out. +class StatisticsViewModel extends ViewModel { + StatisticsViewModel({StudyService? studyService}) : _studyService = studyService; final StudyService? _studyService; StudyService get _study => _studyService ?? bloc.study; diff --git a/lib/view_models/study_page_model.dart b/lib/view_models/study_view_model.dart similarity index 94% rename from lib/view_models/study_page_model.dart rename to lib/view_models/study_view_model.dart index f8e6f9a4..52a8e805 100644 --- a/lib/view_models/study_page_model.dart +++ b/lib/view_models/study_view_model.dart @@ -1,7 +1,11 @@ part of carp_study_app; -/// The view model for the [StudyPage]. Mainly holds the list of messages like -/// news articles to be shown as part of the study. +/// View model for [StudyPage] and [StudyDetailsPage]. +/// +/// State: the study description and responsible party, the deployment status, +/// and the messages (announcements, news) published for the study. +/// +/// Also handles pull-to-refresh: re-fetch messages and re-try the deployment. class StudyPageViewModel extends ViewModel { StudyPageViewModel({ StudyService? studyService, diff --git a/lib/view_models/tasklist_page_model.dart b/lib/view_models/task_list_view_model.dart similarity index 81% rename from lib/view_models/tasklist_page_model.dart rename to lib/view_models/task_list_view_model.dart index ab6dd28e..30edba58 100644 --- a/lib/view_models/tasklist_page_model.dart +++ b/lib/view_models/task_list_view_model.dart @@ -1,6 +1,11 @@ part of carp_study_app; -/// A view model for the [TaskListPage]. +/// View model for [TaskListPage]. +/// +/// State: the user tasks to show, whether the participant-data card is due, and +/// the task that just auto-completed. +/// +/// Starts tasks and auto-completes background ones once they have run. class TaskListPageViewModel extends ViewModel { TaskListPageViewModel({StudyService? studyService}) : _studyService = studyService; @@ -95,18 +100,14 @@ class TaskListPageViewModel extends ViewModel { return tasks; } + /// The tasks the user can still act on, soonest to expire first. + List get pendingTasks => tasks.where((task) => task.availableForUser).toList(); + + /// The tasks that are done or expired, most recently finished first. + List get completedTasks => + tasks.where((task) => task.state == UserTaskState.done || task.state == UserTaskState.expired).toList() + ..sort((t1, t2) => (t2.doneTime ?? DateTime(0)).compareTo(t1.doneTime ?? DateTime(0))); + /// A stream of [UserTask]s as they are generated. Stream get userTaskEvents => AppTaskController().userTaskEvents; - - /// The number of days the user has been part of this study. - /// - /// This is calculated from the study deployment status creation date from the - /// [StudyDeploymentStatus]. - /// Returns 0 if the study deployment status is not available. - int get daysInStudy => (bloc.study.cachedDeploymentStatus != null) - ? DateTime.now().difference(bloc.study.cachedDeploymentStatus!.createdOn).inDays - : 0; - - /// The number of tasks completed so far. - int get taskCompleted => AppTaskController().userTaskQueue.where((task) => task.state == UserTaskState.done).length; } diff --git a/lib/view_models/view_model.dart b/lib/view_models/view_model.dart index 716e0515..bcb72a9f 100644 --- a/lib/view_models/view_model.dart +++ b/lib/view_models/view_model.dart @@ -15,6 +15,14 @@ abstract class ViewModel extends ChangeNotifier { _controller = ctrl; } + /// Handle errors emitted on a measurement stream. + /// + /// Stream errors are not measurements and should not be handled in the data + /// path. View models should log and ignore them so sensing can continue. + void onMeasurementStreamError(Object error, [StackTrace? stackTrace]) { + warning('$runtimeType - measurement stream error: $error'); + } + /// Clear this view model, i.e. delete all data incl. cached data. @mustCallSuper void clear() {} @@ -185,11 +193,14 @@ class HourlyMeasure { String toString() => '$hour:$minute'; } -/// The view model for the entire app. +/// The root view model, owning one instance of every page view model. +/// +/// State: none of its own - it holds the others and forwards [init], [clear], +/// and [dispose] to them, so the pages always read the same instances. class AppViewModel extends ViewModel { final HomePageViewModel _homePageViewModel = HomePageViewModel(); final LoginViewModel _loginViewModel = LoginViewModel(); - final DataVisualizationPageViewModel _dataVisualizationPageViewModel = DataVisualizationPageViewModel(); + final StatisticsViewModel _statisticsViewModel = StatisticsViewModel(); final StudyPageViewModel _studyPageViewModel = StudyPageViewModel(); final TaskListPageViewModel _taskListPageViewModel = TaskListPageViewModel(); final ProfilePageViewModel _profilePageViewModel = ProfilePageViewModel(); @@ -202,7 +213,7 @@ class AppViewModel extends ViewModel { HomePageViewModel get homePageViewModel => _homePageViewModel; LoginViewModel get loginViewModel => _loginViewModel; - DataVisualizationPageViewModel get dataVisualizationPageViewModel => _dataVisualizationPageViewModel; + StatisticsViewModel get statisticsViewModel => _statisticsViewModel; StudyPageViewModel get studyPageViewModel => _studyPageViewModel; TaskListPageViewModel get taskListPageViewModel => _taskListPageViewModel; ProfilePageViewModel get profilePageViewModel => _profilePageViewModel; @@ -217,7 +228,7 @@ class AppViewModel extends ViewModel { _homePageViewModel.init(ctrl); _taskListPageViewModel.init(ctrl); _studyPageViewModel.init(ctrl); - _dataVisualizationPageViewModel.init(ctrl); + _statisticsViewModel.init(ctrl); _devicesPageViewModel.init(ctrl); _profilePageViewModel.init(ctrl); @@ -231,11 +242,10 @@ class AppViewModel extends ViewModel { _homePageViewModel.clear(); _taskListPageViewModel.clear(); _studyPageViewModel.clear(); - _dataVisualizationPageViewModel.clear(); + _statisticsViewModel.clear(); _devicesPageViewModel.clear(); _profilePageViewModel.clear(); - _invitationsListViewModel.clear(); _informedConsentViewModel.clear(); super.clear(); @@ -246,7 +256,7 @@ class AppViewModel extends ViewModel { _homePageViewModel.dispose(); _taskListPageViewModel.dispose(); _studyPageViewModel.dispose(); - _dataVisualizationPageViewModel.dispose(); + _statisticsViewModel.dispose(); _devicesPageViewModel.dispose(); _profilePageViewModel.dispose(); diff --git a/pubspec.lock b/pubspec.lock index 8760cb3e..cf0dd972 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1812,6 +1812,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" + url: "https://pub.dev" + source: hosted + version: "12.0.2" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" shared_preferences: dependency: transitive description: @@ -1900,6 +1916,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" signature: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 99460214..1f672d23 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,8 @@ dependencies: country_code_picker: ^3.3.0 intl_phone_number_input: ^0.7.4 qr_code_scanner_plus: ^2.0.10+1 + share_plus: ^12.0.2 + shimmer: ^3.0.0 dependency_overrides: # polar: 7.5.1 diff --git a/test/heart_rate_data_model_test.dart b/test/heart_rate_data_model_test.dart index 397b6056..cd8bd90a 100644 --- a/test/heart_rate_data_model_test.dart +++ b/test/heart_rate_data_model_test.dart @@ -3,6 +3,12 @@ import 'exports.dart'; @GenerateNiceMocks([MockSpec()]) void main() { group("HeartRateCardViewModel", () { + // These cover the real, measurement-backed behaviour - not the placeholder + // data the charts fall back to while a study has collected nothing, which + // a production deployment never shows. + setUp(() => AppConfig.deploymentMode = DeploymentMode.production); + tearDown(() => AppConfig.deploymentMode = DeploymentMode.local); + test('initializes with an empty heart rate model', () { final controller = MockSmartphoneStudyController(); when(controller.measurements).thenAnswer((_) => const Stream.empty()); @@ -38,25 +44,38 @@ void main() { expect(hr.lastUpdated.day, DateTime.now().day); }); - test('addHeartRate', () { + test('addHeartRate widens the band of both the hour and the weekday', () { HourlyHeartRate hr = HourlyHeartRate(); - hr.addHeartRate(12, 75); - hr.addHeartRate(12, 70); - hr.addHeartRate(12, 80); - hr.addHeartRate(13, 75); - hr.addHeartRate(13, 80); - hr.addHeartRate(13, 85); + hr.addHeartRate(75, weekday: 1, hour: 12); + hr.addHeartRate(70, weekday: 1, hour: 12); + hr.addHeartRate(80, weekday: 1, hour: 12); + hr.addHeartRate(75, weekday: 2, hour: 13); + hr.addHeartRate(80, weekday: 2, hour: 13); + hr.addHeartRate(85, weekday: 2, hour: 13); expect(hr.hourlyHeartRate[12], HeartRateMinMaxPrHour(70, 80)); expect(hr.hourlyHeartRate[13], HeartRateMinMaxPrHour(75, 85)); + expect(hr.dailyHeartRate[1], HeartRateMinMaxPrHour(70, 80)); + expect(hr.dailyHeartRate[2], HeartRateMinMaxPrHour(75, 85)); + }); + + test('addHeartRate without an hour only records the weekday', () { + HourlyHeartRate hr = HourlyHeartRate(); + + hr.addHeartRate(75, weekday: 3); + + expect(hr.dailyHeartRate[3], HeartRateMinMaxPrHour(75, 75)); + expect(hr.hourlyHeartRate.values, everyElement(HeartRateMinMaxPrHour(null, null))); }); test('addHeartRate with invalid input', () { HourlyHeartRate hr = HourlyHeartRate(); - expect(() => hr.addHeartRate(-1, 75), throwsA(isA())); - expect(() => hr.addHeartRate(24, 75), throwsA(isA())); + expect(() => hr.addHeartRate(75, weekday: 1, hour: -1), throwsA(isA())); + expect(() => hr.addHeartRate(75, weekday: 1, hour: 24), throwsA(isA())); + expect(() => hr.addHeartRate(75, weekday: 0), throwsA(isA())); + expect(() => hr.addHeartRate(75, weekday: 8), throwsA(isA())); }); test('resetDataAtMidnight at other times of day', () { diff --git a/test/services_test.dart b/test/services_test.dart index 632828a5..0ec59f9d 100644 --- a/test/services_test.dart +++ b/test/services_test.dart @@ -39,6 +39,20 @@ class _FakeMessageManager extends MessageManager { Future deleteAllMessages() async {} } +/// Records the notifications a service asks for, instead of hitting the platform. +class _FakeNotificationManager implements NotificationManager { + final List titles = []; + + @override + Future createNotification({int? id, required String title, String? body}) async { + titles.add(title); + return id ?? titles.length; + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + class _FakeConsentManager extends InformedConsentManager { RPOrderedTask? document; bool? lastRefresh; @@ -139,6 +153,22 @@ void main() { }); }); + test('notifies only about messages that arrive after the first refresh', () async { + final manager = _FakeMessageManager()..toReturn = [message('a', DateTime(2024, 1, 1))]; + final notifications = _FakeNotificationManager(); + final service = MessageService(manager, notificationManager: notifications); + + await service.refresh(); + expect(notifications.titles, isEmpty, reason: 'the backlog must not be replayed on the first refresh'); + + manager.toReturn = [message('a', DateTime(2024, 1, 1)), message('b', DateTime(2025, 1, 1))]; + await service.refresh(); + expect(notifications.titles, ['message-b']); + + await service.refresh(); + expect(notifications.titles, ['message-b'], reason: 'an already seen message must not notify again'); + }); + test('dispose closes the stream and refresh is still safe', () async { final service = MessageService(_FakeMessageManager()); service.dispose(); @@ -198,6 +228,22 @@ void main() { expect(invitations, [forPhone, unassigned]); expect(auth.invitations, [forPhone, unassigned]); }); + + test('getInvitations sorts by study name, then deployment id', () async { + ActiveParticipationInvitation invitation(String name, String deploymentId) => ActiveParticipationInvitation( + Participation(deploymentId, 'participant-1', AssignedTo()), + StudyInvitation(name), + ); + + // CAWS returns these in no particular order; two deployments of one study + // must still come back in a stable order on every refresh. + final beta = invitation('Beta', 'dep-1'); + final alphaB = invitation('Alpha', 'dep-b'); + final alphaA = invitation('Alpha', 'dep-a'); + when(backend.getInvitations()).thenAnswer((_) async => [beta, alphaB, alphaA]); + + expect(await auth.getInvitations(), [alphaA, alphaB, beta]); + }); }); group('ConsentService', () { @@ -216,53 +262,25 @@ void main() { expect(manager.lastRefresh, isTrue); }); - test('local mode falls back to the locally stored participant flag', () async { - LocalSettings().participant = Participant(studyDeploymentId: 'dep-1'); - expect(await consent.hasBeenAccepted(null), isFalse); - - await consent.accept(); - expect(await consent.hasBeenAccepted(null), isTrue); - }); - - test('caches the consent status for synchronous reads', () async { - LocalSettings().participant = Participant(studyDeploymentId: 'dep-cache'); - - expect(consent.isAccepted, isNull); - expect(await consent.refreshStatus(null), isFalse); - expect(consent.isAccepted, isFalse); - - await consent.accept(); - expect(consent.isAccepted, isTrue); - - consent.reset(); - expect(consent.isAccepted, isNull); - }); - - test('accept notifies listeners', () async { - LocalSettings().participant = Participant(studyDeploymentId: 'dep-1'); - var notified = false; - consent.addListener(() => notified = true); - - await consent.accept(); - expect(notified, isTrue); + test('hasSignedConsent is false without a study, without asking the backend', () async { + expect(await consent.hasSignedConsent(null), isFalse); + verifyNever(backend.getInformedConsentByRole(any, any)); }); - test('non-local mode asks the backend and is false when it fails', () async { - AppConfig.deploymentMode = DeploymentMode.test; + test('hasSignedConsent is false when the backend cannot be reached', () async { final study = SmartphoneStudy(studyDeploymentId: 'dep-1', deviceRoleName: 'phone'); when(backend.getInformedConsentByRole('dep-1', null)).thenAnswer((_) async => throw Exception('offline')); - expect(await consent.hasBeenAccepted(study), isFalse); + expect(await consent.hasSignedConsent(study), isFalse); }); - test('non-local mode is true when the backend has a consent document', () async { - AppConfig.deploymentMode = DeploymentMode.test; + test('hasSignedConsent is true when the backend has a signed consent', () async { final study = SmartphoneStudy(studyDeploymentId: 'dep-1', deviceRoleName: 'phone'); when( backend.getInformedConsentByRole('dep-1', null), ).thenAnswer((_) async => InformedConsentInput(userId: '42', name: 'jdoe', consent: '{}', signatureImage: '')); - expect(await consent.hasBeenAccepted(study), isTrue); + expect(await consent.hasSignedConsent(study), isTrue); }); }); diff --git a/test/services_test.mocks.dart b/test/services_test.mocks.dart index eca70298..85b56352 100644 --- a/test/services_test.mocks.dart +++ b/test/services_test.mocks.dart @@ -5,9 +5,9 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i7; -import 'package:carp_core/carp_core.dart' as _i6; +import 'package:carp_core/carp_core.dart' as _i5; import 'package:carp_mobile_sensing/carp_mobile_sensing.dart' as _i4; -import 'package:carp_study_app/main.dart' as _i5; +import 'package:carp_study_app/main.dart' as _i6; import 'package:carp_webservices/carp_auth/carp_auth.dart' as _i3; import 'package:carp_webservices/carp_services/carp_services.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; @@ -62,10 +62,14 @@ class _FakeBackgroundTaskExecutor_7 extends _i1.SmartFake implements _i4.Backgro _FakeBackgroundTaskExecutor_7(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } +class _FakeDeploymentService_8 extends _i1.SmartFake implements _i5.DeploymentService { + _FakeDeploymentService_8(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + /// A class which mocks [CarpBackend]. /// /// See the documentation for Mockito's code generation for more information. -class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { +class MockCarpBackend extends _i1.Mock implements _i6.CarpBackend { @override Uri get uri => (super.noSuchMethod( @@ -108,19 +112,19 @@ class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { as bool); @override - List<_i6.ActiveParticipationInvitation> get invitations => + List<_i5.ActiveParticipationInvitation> get invitations => (super.noSuchMethod( Invocation.getter(#invitations), - returnValue: <_i6.ActiveParticipationInvitation>[], - returnValueForMissingStub: <_i6.ActiveParticipationInvitation>[], + returnValue: <_i5.ActiveParticipationInvitation>[], + returnValueForMissingStub: <_i5.ActiveParticipationInvitation>[], ) - as List<_i6.ActiveParticipationInvitation>); + as List<_i5.ActiveParticipationInvitation>); @override set user(_i3.CarpUser? user) => super.noSuchMethod(Invocation.setter(#user, user), returnValueForMissingStub: null); @override - set invitations(List<_i6.ActiveParticipationInvitation>? value) => + set invitations(List<_i5.ActiveParticipationInvitation>? value) => super.noSuchMethod(Invocation.setter(#invitations, value), returnValueForMissingStub: null); @override @@ -175,45 +179,50 @@ class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { as _i7.Future); @override - _i7.Future> getInvitations() => + _i7.Future> getInvitations() => (super.noSuchMethod( Invocation.method(#getInvitations, []), - returnValue: _i7.Future>.value( - <_i6.ActiveParticipationInvitation>[], + returnValue: _i7.Future>.value( + <_i5.ActiveParticipationInvitation>[], ), - returnValueForMissingStub: _i7.Future>.value( - <_i6.ActiveParticipationInvitation>[], + returnValueForMissingStub: _i7.Future>.value( + <_i5.ActiveParticipationInvitation>[], ), ) - as _i7.Future>); + as _i7.Future>); @override - _i7.Future<_i6.InformedConsentInput?> uploadInformedConsent(_i8.RPTaskResult? consent) => + _i7.Future<_i5.InformedConsentInput?> uploadInformedConsent(_i8.RPTaskResult? consent) => (super.noSuchMethod( Invocation.method(#uploadInformedConsent, [consent]), - returnValue: _i7.Future<_i6.InformedConsentInput?>.value(), - returnValueForMissingStub: _i7.Future<_i6.InformedConsentInput?>.value(), + returnValue: _i7.Future<_i5.InformedConsentInput?>.value(), + returnValueForMissingStub: _i7.Future<_i5.InformedConsentInput?>.value(), ) - as _i7.Future<_i6.InformedConsentInput?>); + as _i7.Future<_i5.InformedConsentInput?>); @override - _i7.Future<_i6.InformedConsentInput?>? getInformedConsentByRole(String? studyDeploymentId, String? role) => + _i7.Future<_i5.InformedConsentInput?>? getInformedConsentByRole(String? studyDeploymentId, String? role) => (super.noSuchMethod( Invocation.method(#getInformedConsentByRole, [studyDeploymentId, role]), returnValueForMissingStub: null, ) - as _i7.Future<_i6.InformedConsentInput?>?); + as _i7.Future<_i5.InformedConsentInput?>?); } /// A class which mocks [AuthService]. /// /// See the documentation for Mockito's code generation for more information. -class MockAuthService extends _i1.Mock implements _i5.AuthService { +class MockAuthService extends _i1.Mock implements _i6.AuthService { @override bool get isAuthenticated => (super.noSuchMethod(Invocation.getter(#isAuthenticated), returnValue: false, returnValueForMissingStub: false) as bool); + @override + bool get isAnonymous => + (super.noSuchMethod(Invocation.getter(#isAnonymous), returnValue: false, returnValueForMissingStub: false) + as bool); + @override String get username => (super.noSuchMethod( @@ -242,13 +251,13 @@ class MockAuthService extends _i1.Mock implements _i5.AuthService { as Uri); @override - List<_i6.ActiveParticipationInvitation> get invitations => + List<_i5.ActiveParticipationInvitation> get invitations => (super.noSuchMethod( Invocation.getter(#invitations), - returnValue: <_i6.ActiveParticipationInvitation>[], - returnValueForMissingStub: <_i6.ActiveParticipationInvitation>[], + returnValue: <_i5.ActiveParticipationInvitation>[], + returnValueForMissingStub: <_i5.ActiveParticipationInvitation>[], ) - as List<_i6.ActiveParticipationInvitation>); + as List<_i5.ActiveParticipationInvitation>); @override _i7.Future initialize() => @@ -260,17 +269,17 @@ class MockAuthService extends _i1.Mock implements _i5.AuthService { as _i7.Future); @override - _i7.Future> getInvitations() => + _i7.Future> getInvitations() => (super.noSuchMethod( Invocation.method(#getInvitations, []), - returnValue: _i7.Future>.value( - <_i6.ActiveParticipationInvitation>[], + returnValue: _i7.Future>.value( + <_i5.ActiveParticipationInvitation>[], ), - returnValueForMissingStub: _i7.Future>.value( - <_i6.ActiveParticipationInvitation>[], + returnValueForMissingStub: _i7.Future>.value( + <_i5.ActiveParticipationInvitation>[], ), ) - as _i7.Future>); + as _i7.Future>); @override _i7.Future authenticate() => @@ -303,7 +312,7 @@ class MockAuthService extends _i1.Mock implements _i5.AuthService { /// A class which mocks [SystemInfoService]. /// /// See the documentation for Mockito's code generation for more information. -class MockSystemInfoService extends _i1.Mock implements _i5.SystemInfoService { +class MockSystemInfoService extends _i1.Mock implements _i6.SystemInfoService { @override _i7.Future checkConnectivity() => (super.noSuchMethod( @@ -330,6 +339,15 @@ class MockSystemInfoService extends _i1.Mock implements _i5.SystemInfoService { returnValueForMissingStub: _i7.Future.value(), ) as _i7.Future); + + @override + _i7.Future openAppStore() => + (super.noSuchMethod( + Invocation.method(#openAppStore, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); } /// A class which mocks [UserTask]. @@ -504,7 +522,7 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { super.noSuchMethod(Invocation.setter(#backgroundTaskExecutor, value), returnValueForMissingStub: null); @override - set result(_i6.Data? value) => super.noSuchMethod(Invocation.setter(#result, value), returnValueForMissingStub: null); + set result(_i5.Data? value) => super.noSuchMethod(Invocation.setter(#result, value), returnValueForMissingStub: null); @override void onStart() => super.noSuchMethod(Invocation.method(#onStart, []), returnValueForMissingStub: null); @@ -517,7 +535,7 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { void onExpired() => super.noSuchMethod(Invocation.method(#onExpired, []), returnValueForMissingStub: null); @override - void onDone({bool? dequeue = false, _i6.Data? result}) => super.noSuchMethod( + void onDone({bool? dequeue = false, _i5.Data? result}) => super.noSuchMethod( Invocation.method(#onDone, [], {#dequeue: dequeue, #result: result}), returnValueForMissingStub: null, ); @@ -529,7 +547,16 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { /// A class which mocks [StudyService]. /// /// See the documentation for Mockito's code generation for more information. -class MockStudyService extends _i1.Mock implements _i5.StudyService { +class MockStudyService extends _i1.Mock implements _i6.StudyService { + @override + _i5.DeploymentService get deploymentService => + (super.noSuchMethod( + Invocation.getter(#deploymentService), + returnValue: _FakeDeploymentService_8(this, Invocation.getter(#deploymentService)), + returnValueForMissingStub: _FakeDeploymentService_8(this, Invocation.getter(#deploymentService)), + ) + as _i5.DeploymentService); + @override bool get hasStudy => (super.noSuchMethod(Invocation.getter(#hasStudy), returnValue: false, returnValueForMissingStub: false) as bool); @@ -540,39 +567,39 @@ class MockStudyService extends _i1.Mock implements _i5.StudyService { as bool); @override - Set<_i6.ExpectedParticipantData?> get expectedParticipantData => + Set<_i5.ExpectedParticipantData?> get expectedParticipantData => (super.noSuchMethod( Invocation.getter(#expectedParticipantData), - returnValue: <_i6.ExpectedParticipantData?>{}, - returnValueForMissingStub: <_i6.ExpectedParticipantData?>{}, + returnValue: <_i5.ExpectedParticipantData?>{}, + returnValueForMissingStub: <_i5.ExpectedParticipantData?>{}, ) - as Set<_i6.ExpectedParticipantData?>); + as Set<_i5.ExpectedParticipantData?>); @override bool get isRunning => (super.noSuchMethod(Invocation.getter(#isRunning), returnValue: false, returnValueForMissingStub: false) as bool); @override - Iterable<_i5.DeviceViewModel> get deploymentDevices => + Iterable<_i6.DeviceViewModel> get deploymentDevices => (super.noSuchMethod( Invocation.getter(#deploymentDevices), - returnValue: <_i5.DeviceViewModel>[], - returnValueForMissingStub: <_i5.DeviceViewModel>[], + returnValue: <_i6.DeviceViewModel>[], + returnValueForMissingStub: <_i6.DeviceViewModel>[], ) - as Iterable<_i5.DeviceViewModel>); + as Iterable<_i6.DeviceViewModel>); @override set study(_i4.SmartphoneStudy? study) => super.noSuchMethod(Invocation.setter(#study, study), returnValueForMissingStub: null); @override - _i7.Future<_i6.StudyDeploymentStatus?> refreshDeploymentStatus() => + _i7.Future<_i5.StudyDeploymentStatus?> refreshDeploymentStatus() => (super.noSuchMethod( Invocation.method(#refreshDeploymentStatus, []), - returnValue: _i7.Future<_i6.StudyDeploymentStatus?>.value(), - returnValueForMissingStub: _i7.Future<_i6.StudyDeploymentStatus?>.value(), + returnValue: _i7.Future<_i5.StudyDeploymentStatus?>.value(), + returnValueForMissingStub: _i7.Future<_i5.StudyDeploymentStatus?>.value(), ) - as _i7.Future<_i6.StudyDeploymentStatus?>); + as _i7.Future<_i5.StudyDeploymentStatus?>); @override _i7.Future configure() => @@ -584,13 +611,22 @@ class MockStudyService extends _i1.Mock implements _i5.StudyService { as _i7.Future); @override - _i7.Future<_i6.StudyStatus?> tryDeployment() => + _i7.Future<_i5.StudyStatus?> addStudy(_i4.SmartphoneStudy? study) => + (super.noSuchMethod( + Invocation.method(#addStudy, [study]), + returnValue: _i7.Future<_i5.StudyStatus?>.value(), + returnValueForMissingStub: _i7.Future<_i5.StudyStatus?>.value(), + ) + as _i7.Future<_i5.StudyStatus?>); + + @override + _i7.Future<_i5.StudyStatus?> tryDeployment() => (super.noSuchMethod( Invocation.method(#tryDeployment, []), - returnValue: _i7.Future<_i6.StudyStatus?>.value(), - returnValueForMissingStub: _i7.Future<_i6.StudyStatus?>.value(), + returnValue: _i7.Future<_i5.StudyStatus?>.value(), + returnValueForMissingStub: _i7.Future<_i5.StudyStatus?>.value(), ) - as _i7.Future<_i6.StudyStatus?>); + as _i7.Future<_i5.StudyStatus?>); @override _i7.Future start() => @@ -602,7 +638,7 @@ class MockStudyService extends _i1.Mock implements _i5.StudyService { as _i7.Future); @override - void addMeasurement(_i6.Measurement? measurement) => + void addMeasurement(_i5.Measurement? measurement) => super.noSuchMethod(Invocation.method(#addMeasurement, [measurement]), returnValueForMissingStub: null); @override @@ -621,16 +657,20 @@ class MockStudyService extends _i1.Mock implements _i5.StudyService { as bool); @override - _i7.Future> getParticipantDataListFromDeployment() => + void translateProtocol([_i8.RPLocalizations? localization]) => + super.noSuchMethod(Invocation.method(#translateProtocol, [localization]), returnValueForMissingStub: null); + + @override + _i7.Future> getParticipantDataListFromDeployment() => (super.noSuchMethod( Invocation.method(#getParticipantDataListFromDeployment, []), - returnValue: _i7.Future>.value(<_i6.ParticipantData>[]), - returnValueForMissingStub: _i7.Future>.value(<_i6.ParticipantData>[]), + returnValue: _i7.Future>.value(<_i5.ParticipantData>[]), + returnValueForMissingStub: _i7.Future>.value(<_i5.ParticipantData>[]), ) - as _i7.Future>); + as _i7.Future>); @override - void setParticipantData(Map? data) => + void setParticipantData(Map? data) => super.noSuchMethod(Invocation.method(#setParticipantData, [data]), returnValueForMissingStub: null); @override diff --git a/test/view_models_test.dart b/test/view_models_test.dart index e26ca071..425e542a 100644 --- a/test/view_models_test.dart +++ b/test/view_models_test.dart @@ -1,5 +1,6 @@ import 'package:carp_backend/carp_backend.dart'; import 'package:carp_audio_package/media.dart'; +import 'package:carp_context_package/carp_context_package.dart'; import 'package:cognition_package/cognition_package.dart'; import 'package:fake_async/fake_async.dart'; import 'package:research_package/research_package.dart'; @@ -165,7 +166,7 @@ void main() { expect(model.isLoading, isFalse); expect(model.invitations, [invitation]); - expect(model.getInvitation('participant-1'), invitation); + expect(model.getInvitation('dep-1'), invitation); expect(notified, isTrue); }); @@ -179,14 +180,27 @@ void main() { expect(model.invitations, isEmpty); }); - ActiveParticipationInvitation invitation(String participantId) => - ActiveParticipationInvitation(Participation('dep-1', participantId, AssignedTo()), StudyInvitation('Study')); + ActiveParticipationInvitation invitation(String deploymentId) => ActiveParticipationInvitation( + Participation(deploymentId, 'participant-1', AssignedTo()), + StudyInvitation('Study'), + ); test('landingRoute targets the single invitation detail when there is exactly one', () async { - when(auth.getInvitations()).thenAnswer((_) async => [invitation('participant-1')]); + when(auth.getInvitations()).thenAnswer((_) async => [invitation('dep-1')]); + await model.loadInvitations(); + + expect(model.landingRoute, '${InvitationDetailsPage.route}/dep-1'); + }); + + test('getInvitation tells apart two deployments of one study', () async { + // Same participant id on both - only the deployment id is unique. + final first = invitation('dep-1'); + final second = invitation('dep-2'); + when(auth.getInvitations()).thenAnswer((_) async => [first, second]); await model.loadInvitations(); - expect(model.landingRoute, '${InvitationDetailsPage.route}/participant-1'); + expect(model.getInvitation('dep-1'), same(first)); + expect(model.getInvitation('dep-2'), same(second)); }); test('landingRoute targets the list for zero or multiple invitations', () async { @@ -280,13 +294,85 @@ void main() { }); }); - group('DataVisualizationPageViewModel', () { + group('DemoChartData', () { + // The demo measurements exist to prove the aggregation the live streams + // use: they are real CAMS data types, folded by the very same code. + setUp(() => AppConfig.deploymentMode = DeploymentMode.local); + + test('step measurements are cumulative StepCounts that aggregate per day', () { + final measurements = DemoChartData.stepMeasurements; + expect(measurements, everyElement(isA().having((m) => m.data, 'data', isA()))); + + final steps = StepsCardViewModel().weeklySteps; + expect(steps.length, 7); + expect(steps.values.every((count) => count >= 0), isTrue); + expect(steps.values.reduce((a, b) => a + b), greaterThan(0)); + }); + + test('heart rate measurements are PolarHR, split into hours and weekdays', () { + expect( + DemoChartData.heartRateMeasurements, + everyElement(isA().having((m) => m.data, 'data', isA())), + ); + + final model = HeartRateCardViewModel(); + expect(model.hourlyHeartRate.values.where((band) => band.max != null), isNotEmpty); + expect(model.dailyHeartRate.values.where((band) => band.max != null).length, 7); + expect(model.currentHeartRate, isNotNull); + }); + + test('activity measurements are Activities that aggregate into minutes', () { + expect( + DemoChartData.activityMeasurements, + everyElement(isA().having((m) => m.data, 'data', isA())), + ); + + final activities = ActivityCardViewModel().activities; + expect(activities[ActivityType.WALKING]!.values.reduce((a, b) => a + b), greaterThan(0)); + expect(activities[ActivityType.RUNNING]!.values.reduce((a, b) => a + b), greaterThan(0)); + }); + + test('never shows on a production deployment', () { + AppConfig.deploymentMode = DeploymentMode.production; + expect(AppConfig.useDemoChartData, isFalse); + // And with it off, an untouched model reports no data rather than demo. + expect(StepsCardViewModel().weeklySteps.values, everyElement(0)); + expect(HeartRateCardViewModel().currentHeartRate, isNull); + expect(ActivityCardViewModel().activities[ActivityType.WALKING]!.values, everyElement(0)); + + AppConfig.deploymentMode = DeploymentMode.local; + expect(AppConfig.useDemoChartData, isTrue); + }); + }); + + group('StudyProgressCardViewModel', () { + test('completionsPerDay buckets the last 14 days, oldest first', () { + final today = DateTime(2026, 7, 28, 15, 30); + + final counts = StudyProgressCardViewModel.completionsPerDay([ + DateTime(2026, 7, 28, 9), // today, twice + DateTime(2026, 7, 28, 11), + DateTime(2026, 7, 27, 20), // yesterday + DateTime(2026, 7, 15, 8), // the oldest day still inside the window + DateTime(2026, 7, 14, 8), // one day too old + null, // a done task with no timestamp + ], today: today); + + expect(counts.length, 14); + expect(counts.last, 2, reason: 'today is the newest bucket'); + expect(counts[12], 1); + expect(counts.first, 1, reason: 'the window reaches back 13 days'); + expect(counts.reduce((a, b) => a + b), 4, reason: 'older and null entries are dropped'); + }); + }); + + group('StatisticsViewModel', () { test('precomputes card availability from the deployment on init', () { final study = MockStudyService(); when(study.hasUserTasks()).thenReturn(true); when(study.hasMeasure(PolarSamplingPackage.HR)).thenReturn(true); when(study.hasMeasure(MediaSamplingPackage.AUDIO)).thenReturn(true); - final model = DataVisualizationPageViewModel(studyService: study); + final model = StatisticsViewModel(studyService: study); model.init(MockSmartphoneStudyController());