From 2c23dd12f99826ca33b729d60a95290433a1b2ef Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 10 Jul 2026 15:06:42 +0200 Subject: [PATCH 01/24] build(android): add gradle flags from Flutter migrator --- android/gradle.properties | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/android/gradle.properties b/android/gradle.properties index 7d40b1c3..e7b01d3c 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -3,4 +3,8 @@ org.gradle.caching=true org.gradle.parallel=true org.gradle.daemon=true android.enableJetifier=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false From 6dfbc63abda4a187d0a0f8cc8d3190eddbd0fd41 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 10 Jul 2026 15:06:42 +0200 Subject: [PATCH 02/24] fix(android): add ic_launcher drawable for local notifications --- .../app/src/main/res/drawable/ic_launcher.png | Bin 0 -> 2741 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 android/app/src/main/res/drawable/ic_launcher.png diff --git a/android/app/src/main/res/drawable/ic_launcher.png b/android/app/src/main/res/drawable/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..6b6b8b8a908798c18a79608c98fa5878c94e2f3b GIT binary patch literal 2741 zcmXw*dpuNY7svPB?VK5Mni;nunW1qHDU}SflQ4wI2{ov}sW4Hx9CK1;Pbigpk;~+A z+{#^&OJu5XbRu^t$r+r`(S=;YjCc3GpZAZwpZ(eEdDi-^y+3QM?{%iLoxH57EC2xV z40{__Y!&{va5C84b@0?r03aL4u-SWv3x2w5nWAn+uw6cY)63hft@?~|oJe>eQ$^@d z#eLqNV2vYIGAPY@i)wM3(=(-sy{^SY7tHNumk$;f2lrhronCzUp1!h%&&Lls{dXY? zh0Qv10oFi0+aJQ2od3=UXM?&{@EfRWO@xB@XA|z4m`p1uW(bTJj|U=l2`4^k38U4K z_FWL}%)X})L_Ap-?WQxX2Ew&D;-}Ftfh{V!H4_U!gUl&549#pJvYjKx_Yn~^=cUtw zb65f`>i<$rz#A8XTmZh_PzR~Q<@-b4>v#LkPwvfI=O^|CnK7=1>Uy+ z$g8UC*JOJDB<-!sw4Ex(!YIj~jzIv>_MWME8MfbTN5eJ}-{K&`Wca20mZ3&u_WXykn~>7Ca9%|?d8{7Kuh$v84XO)wnA0xp)Q z9rVydfn?-Cj0=PlR|VU6Uk9K=P(a|uD-3NE`0HLOcR%(Knv0TtF~xM-Hj4}}9R}{n zf+S40slN6_a)R3E`>)3?ZI7CkKGx8VZQ9HeJX@SoV3N+(CS(X0SEV5sOE&8e>g<%fN|Fi)kj6Mc&*1~ zDP!MM;W2njqF}`wpJ@-zG!)WI-j?#$oinAmPzaF|@9E8uh~WUoCLCDV98amRLmG3t za(P8Kg3cR*#DE*Q;z|$b5zp0wXjq zzwz!Po7X(2vcntaalN0ij378~$;{mI4jtIqE@#-o3Tad{#w z@-|8K82VR31^RTvE3H4F_452dR51PVd83-Mr)1O`NE~`_`0IKz`<)p=)@m)0-8yng zRbaA-Uo8#MLiHb=s2uV`*zrQw)mjq!)kvf0cMV9hqL*Fdk}=7sGXRQJ{C;R8EW|s4V>Oko1i>zCel5>S)}$#m_K58RqyL$1b1$j& zoRfji;v%IX2FN9~R)(OCK;U=M<6!2Sb+}%t*d66+c&OluV-fOfyeD`luOhz34?BUs z$Mcd)$S|3qnCOUE=oz&P9kY>hm-kY~<+je$)wRVPbr~N&S~Y)ZE}jEQcS?Dc=2WGWI0HyeJGAhM^Hx21$+fer zJubZUI+DPk6Gfqea@m)?)M!uK`#e_SVg^r92NNeht_*S>rG8}r=}r3`W2y?4T55px z3%8`KFT1H)J?U8e1+PtL1Z`J#H;gUWPrbrNW`-D`;b?&KbvdsTWJ03`5+!%l=92ZW50C}Uopqf_RnF%DeQeuq|!h@b?XH%Ejji!1DgocaP7@gz~BS4cd^SWYiX= zh9&oQzesz0-m2U?UdVE(B_PX7)Uf`SB5!tV+tTDxl0$pNR8-2`sm!LTKDrSc%?p{P zRnMK|{8^s0ZQi2pSa9XHsFd49L38oHgS{`zv!oy{l#lp+8a=w2s*;`eecxZl8ZRqs z9Gpek|Z^*zLCB(0IE$;!B0F>rzVzARFE>bi3Z)Ag|hW%yfJYd}!u2R;I$& zE}yAX60Y>fS)=hqt!!5Dm|kLrVs`V8cNR_$Ngd5XoL5s7vimZdfvf?L6w`rkQT;A8H>qTwptd1*rS~rioS3s5cS)<)z z$5t}4{=~=cf|EDV!29VbHhtbe5W^wDn6X=s%aQfsws-09bAR-xwdGqG#bsJh`~wKhiX*haQto_vEFVbjZCOQK8xU#~R z%mX)GInYFv2M{yY(!jZn`_KzkkVVQbldDC(3Swy@;VFr){G+GVAGZwJzWK|YZ>DqU zTbL!$>_Wq>%UJW3^?Lnp%JH90vbK=+l60><+@9)|fxfBvlBEra`27RLeOEIcWNt}l zAW@Eof7_%TOxj(=E0R5PLYx5`H0crFD7#&)ukebO-q%Fpg({0?JUwOpV{6Mn%jLqu zDB+>i#%iajhQ7bH=G$~KzmWE7g~oP`dw$IN6;!Oa8m@bA*p9PQeKi@U>Z?iIAqohR z-YdU({c5$e4xcR^$+6ZyEU)AtU%std=W=kB11ukM(Rcn!2&u&c`g`z#W!w6?sNA#e zU;Sy7+~pG_6^`Uwp?HU)7wwS!+T!6HK-o{xB4nM`l+xGY*aXkr`X@h>`&90v6ueU> zHwcN47Df(dVlzyw{Ed>~6acE-dN)?sXg6()G3V;)=gsxS*n`Xu>Rba@l284+ T_PZt>djl}`JKL05v# Date: Fri, 10 Jul 2026 15:06:54 +0200 Subject: [PATCH 03/24] fix(view_models): log and ignore measurement stream errors --- lib/view_models/cards/activity_data_model.dart | 2 +- lib/view_models/cards/heart_rate_data_model.dart | 2 +- lib/view_models/cards/measurements_data_model.dart | 2 +- lib/view_models/cards/mobility_data_model.dart | 2 +- lib/view_models/cards/steps_data_model.dart | 2 +- lib/view_models/view_model.dart | 8 ++++++++ 6 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/view_models/cards/activity_data_model.dart b/lib/view_models/cards/activity_data_model.dart index 7a1d732d..cd6b3d94 100644 --- a/lib/view_models/cards/activity_data_model.dart +++ b/lib/view_models/cards/activity_data_model.dart @@ -48,7 +48,7 @@ class ActivityCardViewModel extends SerializableViewModel { // and then save the new activity _lastActivity = measurement; } - }); + }, onError: onMeasurementStreamError); } } diff --git a/lib/view_models/cards/heart_rate_data_model.dart b/lib/view_models/cards/heart_rate_data_model.dart index 48e935c0..26ff32dd 100644 --- a/lib/view_models/cards/heart_rate_data_model.dart +++ b/lib/view_models/cards/heart_rate_data_model.dart @@ -43,7 +43,7 @@ class HeartRateCardViewModel extends SerializableViewModel { if (hr > (model.maxHeartRate ?? 0)) model.maxHeartRate = hr; if (hr < (model.minHeartRate ?? 100000)) model.minHeartRate = hr; model.resetDataAtMidnight(); - }); + }, onError: onMeasurementStreamError); } } 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..d4fbb3d4 100644 --- a/lib/view_models/cards/steps_data_model.dart +++ b/lib/view_models/cards/steps_data_model.dart @@ -43,7 +43,7 @@ class StepsCardViewModel extends SerializableViewModel { } _lastStep = step; - }); + }, onError: onMeasurementStreamError); } } diff --git a/lib/view_models/view_model.dart b/lib/view_models/view_model.dart index 716e0515..96ab0168 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() {} From f34d5b17b1c6873cc9a031bd457481df4de17085 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 10 Jul 2026 15:06:54 +0200 Subject: [PATCH 04/24] fix(profile): read study id from study instead of deployment --- lib/view_models/profile_page_model.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_models/profile_page_model.dart b/lib/view_models/profile_page_model.dart index ee9e9706..24a2f515 100644 --- a/lib/view_models/profile_page_model.dart +++ b/lib/view_models/profile_page_model.dart @@ -33,7 +33,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 ?? ''; From 9817f0cb604707f1c85a977791166762ce37b491 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 10 Jul 2026 15:07:26 +0200 Subject: [PATCH 05/24] feat(ui): add design 2.0 app shell, home page, and tab routing Move the bottom navigation into a dedicated CarpAppShell and make the redesigned HomePage the landing tab, replacing StudyPage (kept as a redirect alias). The home page shows the study title, app-update and connection-status cards, and live days-in-study / tasks-completed stats from the deployment status and the user task queue. Tab switches now animate with a fade/scale transition; each tab page gets a unique page key so the transition actually runs. Informed consent moves to a root-level route so the bottom bar does not bleed through. DeviceViewModel bridges device status events into listener notifications, and nav bar labels are renamed to Home / Statistics / Connections. --- assets/lang/da.json | 6 +- assets/lang/en.json | 6 +- assets/lang/es.json | 6 +- lib/carp_study_app.dart | 54 +++-- lib/main.dart | 2 + lib/services/system_info_service.dart | 9 + lib/ui/cards/connections_status_card.dart | 158 ++++++++++++++ lib/ui/pages/app_shell.dart | 116 ++++++++++ lib/ui/pages/home_page.dart | 250 +++++++++++++--------- lib/ui/pages/informed_consent_page.dart | 2 +- lib/ui/pages/invitation_details_page.dart | 2 +- lib/ui/widgets/carp_app_bar.dart | 18 ++ lib/view_models/device_view_models.dart | 27 +++ lib/view_models/home_page_model.dart | 99 ++++++++- 14 files changed, 628 insertions(+), 127 deletions(-) create mode 100644 lib/ui/cards/connections_status_card.dart create mode 100644 lib/ui/pages/app_shell.dart diff --git a/assets/lang/da.json b/assets/lang/da.json index 94ae30a2..124138e3 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", diff --git a/assets/lang/en.json b/assets/lang/en.json index 7bad5d4a..df9fd253 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", diff --git a/assets/lang/es.json b/assets/lang/es.json index aac23e65..b28a0909 100644 --- a/assets/lang/es.json +++ b/assets/lang/es.json @@ -1,8 +1,8 @@ { "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", diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index 4147cba6..6c66f6cf 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -65,41 +65,41 @@ class CarpAppState extends State { ShellRoute( navigatorKey: _shellNavigatorKey, builder: (BuildContext context, GoRouterState state, Widget child) => - HomePage(model: bloc.appViewModel.homePageViewModel, child: child), + CarpAppShell(model: bloc.appViewModel.homePageViewModel, 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, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( + key: state.pageKey, child: DataVisualizationPage(bloc.appViewModel.dataVisualizationPageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), @@ -108,6 +108,7 @@ class CarpAppState extends State { path: DeviceListPage.route, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( + key: state.pageKey, child: DeviceListPage(model: bloc.appViewModel.devicesPageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), @@ -116,12 +117,20 @@ class CarpAppState extends State { path: ProfilePage.route, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( + key: state.pageKey, child: ProfilePage(bloc.appViewModel.profilePageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), ), ], ), + // 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, @@ -258,9 +267,18 @@ 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, -) => FadeTransition(opacity: animation, child: 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), + ); +} diff --git a/lib/main.dart b/lib/main.dart index 335a4d9d..8f249a9b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -101,6 +101,7 @@ part 'view_models/user_tasks.dart'; part 'carp_study_app.dart'; part 'ui/pages/informed_consent_page.dart'; +part 'ui/pages/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'; @@ -146,6 +147,7 @@ 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'; 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/connections_status_card.dart b/lib/ui/cards/connections_status_card.dart new file mode 100644 index 00000000..9a2baf2b --- /dev/null +++ b/lib/ui/cards/connections_status_card.dart @@ -0,0 +1,158 @@ +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, badge) = switch (model.connectionState) { + HomeConnectionState.all => (_green, Icons.sync, 'Connected & sending data', 'LIVE'), + HomeConnectionState.partial => (_amber, Icons.sync_problem, 'Partially connected', 'ACTION'), + HomeConnectionState.none => (_rose, Icons.sync_disabled, 'No devices connected', 'SETUP'), + }; + + final sources = switch (model.connectionState) { + HomeConnectionState.all => 'All $total sources active', + HomeConnectionState.partial => '$active of $total sources active', + HomeConnectionState.none => 'No sources active', + }; + final hint = switch (model.connectionState) { + HomeConnectionState.all => 'tap to view', + HomeConnectionState.partial => 'tap to fix', + HomeConnectionState.none => 'tap to set up', + }; + + 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( + 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: [ + Row( + children: [ + Flexible(child: Text(title, style: fs20fw700.copyWith(color: colors.grey900))), + const SizedBox(width: 8), + _badge(accent, badge), + ], + ), + const SizedBox(height: 4), + Text('$sources • $hint', 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), + ], + ), + ), + ), + ], + ); + } + + Widget _badge(Color accent, String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration(color: accent.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(100)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle, size: 8, color: accent), + const SizedBox(width: 5), + Text(label, style: fs12fw600.copyWith(color: accent)), + ], + ), + ); + } +} diff --git a/lib/ui/pages/app_shell.dart b/lib/ui/pages/app_shell.dart new file mode 100644 index 00000000..438d234e --- /dev/null +++ b/lib/ui/pages/app_shell.dart @@ -0,0 +1,116 @@ +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). +/// +/// All setup orchestration (consent gating, study configuration, starting +/// sensing) is owned by the [AppBloc] and the router redirect - not this page. +class CarpAppShell extends StatefulWidget { + final HomePageViewModel model; + final Widget child; + const CarpAppShell({required this.model, required this.child, super.key}); + + @override + CarpAppShellState createState() => CarpAppShellState(); +} + +class CarpAppShellState extends State { + @override + void initState() { + super.initState(); + widget.model.addListener(_onModelChanged); + } + + @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), + bottomNavigationBar: 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.leaderboard), + label: locale.translate('app_home.nav_bar_item.statistics'), + activeIcon: const Icon(Icons.leaderboard), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.devices_other), + label: locale.translate('app_home.nav_bar_item.connections'), + activeIcon: const Icon(Icons.devices_other), + ), + ], + 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(DataVisualizationPage.route)) { + return 2; + } + if (location.startsWith(DeviceListPage.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(DataVisualizationPage.route); + break; + case 3: + context.go(DeviceListPage.route); + break; + case -1: + context.go(CarpAppState.homeRoute); + break; + } + } +} diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index 31f21a0d..638968eb 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -1,116 +1,174 @@ 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. +// ponytail: hardcoded content; wire study title and feeds later. +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), + ), + const CarpPageTitle('UX Data collection study'), + AppUpdateCard(model: model), + ConnectionsStatusCard(model: model), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Expanded( + child: _statTile( + colors, + Icons.calendar_today_outlined, + colors.primary!, + 'Days in Study', + '${model.daysInStudy}', + ), + ), + Expanded( + child: _statTile(colors, Icons.task_alt, colors.warningColor!, 'Task completed', '${model.taskCompleted}'), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Feeds', style: fs22fw700.copyWith(color: colors.grey900)), + ), + _feedCard(colors, 'Connect Polar Strap', "Sync your heart rate sensor for today's session to ensure data accuracy."), + _feedCard(colors, 'Health Issues', "Sync your heart rate sensor for today's session."), + ], + ), + ), + ), + ); + } -class HomePageState extends State { - @override - void initState() { - super.initState(); - widget.model.addListener(_onModelChanged); + Widget _statTile(CarpColors colors, IconData icon, Color iconColor, String label, String value) { + return StudiesMaterial( + backgroundColor: colors.grey50!, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration(color: iconColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10)), + child: Icon(icon, color: iconColor, size: 20), + ), + const SizedBox(height: 24), + Text(label, style: fs14fw600.copyWith(color: colors.grey600)), + const SizedBox(height: 4), + Text(value, style: fs30fw800.copyWith(color: colors.grey900)), + ], + ), + ), + ); } - @override - void dispose() { - widget.model.removeListener(_onModelChanged); - super.dispose(); + Widget _feedCard(CarpColors colors, String title, String body) { + return StudiesMaterial( + backgroundColor: colors.grey50!, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: Text(title, style: fs20fw700.copyWith(color: colors.grey900))), + _circleIcon(Icons.campaign, colors.primary!), + ], + ), + const SizedBox(height: 4), + Text('Subtitle', style: fs14fw600.copyWith(color: colors.grey600)), + const SizedBox(height: 8), + Text(body, 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('Today', style: fs12fw600.copyWith(color: colors.grey500)), + ], + ), + ], + ), + ), + ); } - void _onModelChanged() { - if (widget.model.shouldPromptHealthConnectInstall && mounted) { - widget.model.healthConnectPromptShown(); - showDialog( - context: context, - barrierDismissible: true, - builder: (context) => InstallHealthConnectDialog(context), - ); - } + Widget _circleIcon(IconData icon, Color color) { + return Material( + color: color, + shape: const CircleBorder(), + child: Padding(padding: const EdgeInsets.all(8), child: Icon(icon, color: Colors.white, size: 20)), + ); } +} + +/// Home banner shown only when a newer app version is available in the store. +/// Tapping opens the store; the X dismisses it for this session. +class AppUpdateCard extends StatefulWidget { + final HomePageViewModel model; + const AppUpdateCard({required this.model, super.key}); + + @override + State createState() => _AppUpdateCardState(); +} + +class _AppUpdateCardState extends State { + bool _dismissed = false; @override Widget build(BuildContext context) { - RPLocalizations locale = RPLocalizations.of(context)!; + if (!widget.model.appUpdateAvailable || _dismissed) 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), + return StudiesMaterial( + backgroundColor: colors.grey50!, + child: InkWell( + onTap: widget.model.openAppStore, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration(color: colors.primary!.withValues(alpha: 0.12), shape: BoxShape.circle), + child: Icon(Icons.info_outline, color: colors.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + locale.translate('pages.about.app_update'), + style: fs16fw600.copyWith(color: colors.grey900), + ), + ), + IconButton( + icon: Icon(Icons.close, color: colors.grey500), + onPressed: () => setState(() => _dismissed = true), + ), + ], ), - 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), + ), ), ); } - - 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; - } - - 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; - } - } } diff --git a/lib/ui/pages/informed_consent_page.dart b/lib/ui/pages/informed_consent_page.dart index d9afa95c..d6662e8e 100644 --- a/lib/ui/pages/informed_consent_page.dart +++ b/lib/ui/pages/informed_consent_page.dart @@ -1,7 +1,7 @@ part of carp_study_app; class InformedConsentPage extends StatefulWidget { - static const String route = '/study/consent'; + static const String route = '/consent'; final InformedConsentViewModel model; const InformedConsentPage({super.key, required this.model}); diff --git a/lib/ui/pages/invitation_details_page.dart b/lib/ui/pages/invitation_details_page.dart index fc709221..a650b21d 100644 --- a/lib/ui/pages/invitation_details_page.dart +++ b/lib/ui/pages/invitation_details_page.dart @@ -136,7 +136,7 @@ class InvitationDetailsPage extends StatelessWidget { child: TextButton( onPressed: () { model.accept(invitation); - context.go(StudyPage.route); + context.go(HomePage.route); }, child: Text( locale.translate("invitation.accept_invite"), diff --git a/lib/ui/widgets/carp_app_bar.dart b/lib/ui/widgets/carp_app_bar.dart index 38719d87..6f50d8f3 100644 --- a/lib/ui/widgets/carp_app_bar.dart +++ b/lib/ui/widgets/carp_app_bar.dart @@ -1,5 +1,23 @@ 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)), + ), + ); + } +} + class CarpAppBar extends StatelessWidget { final bool hasProfileIcon; const CarpAppBar({super.key, this.hasProfileIcon = false}); diff --git a/lib/view_models/device_view_models.dart b/lib/view_models/device_view_models.dart index 1370d1f2..f06802f4 100644 --- a/lib/view_models/device_view_models.dart +++ b/lib/view_models/device_view_models.dart @@ -35,6 +35,33 @@ 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 index 3b3ab22c..82cbab1b 100644 --- a/lib/view_models/home_page_model.dart +++ b/lib/view_models/home_page_model.dart @@ -1,24 +1,108 @@ part of carp_study_app; +/// The 3-state connection summary shown on the home page. +enum HomeConnectionState { all, partial, none } + /// The view model for the [HomePage]. +/// +/// Owns the home page's service-backed, reactive data: the connection summary +/// (deployment devices minus the phone), whether an app update is available, and +/// the one-shot Health Connect install prompt. The UI reads these and rebuilds +/// via [ListenableBuilder]; it never touches the services or streams directly. class HomePageViewModel extends ViewModel { - HomePageViewModel({SystemInfoService? systemInfoService}) : _systemInfoService = systemInfoService; + HomePageViewModel({SystemInfoService? systemInfoService, StudyService? studyService}) + : _systemInfoService = systemInfoService, + _studyService = studyService; final SystemInfoService? _systemInfoService; + final StudyService? _studyService; SystemInfoService get _system => _systemInfoService ?? bloc.system; + StudyService get _study => _studyService ?? bloc.study; bool _healthConnectPromptPending = false; + bool _appUpdateAvailable = false; + List _connectionSources = const []; + final List> _deviceSubs = []; + StreamSubscription? _userTaskSub; + bool _blocAttached = false; + + /// The number of days the user has been part of the study. + int get daysInStudy => (_study.cachedDeploymentStatus != null) + ? DateTime.now().difference(_study.cachedDeploymentStatus!.createdOn).inDays + : 0; + + /// The number of tasks completed so far. + int get taskCompleted => AppTaskController().userTaskQueue.where((task) => task.state == UserTaskState.done).length; /// Should the user be prompted to install Health Connect? - /// One-shot - the page calls [healthConnectPromptShown] once shown. + /// 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()); unawaited(_checkHealthConnectInstallation()); + unawaited(_checkAppUpdate()); + } + + // 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 { @@ -30,7 +114,18 @@ class HomePageViewModel extends ViewModel { @override void clear() { + _cancelDeviceSubs(); + _connectionSources = const []; _healthConnectPromptPending = false; + _appUpdateAvailable = false; super.clear(); } + + @override + void dispose() { + if (_blocAttached) bloc.removeListener(_syncSources); + _cancelDeviceSubs(); + _userTaskSub?.cancel(); + super.dispose(); + } } From 0783ad8896bae719030905eb0283ff4dcadb0c9e Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 10 Jul 2026 15:07:42 +0200 Subject: [PATCH 06/24] refactor(ui): unify tab page titles and remove scoreboard card All four tab pages render their title through a shared CarpPageTitle widget so font and padding stay consistent. The scoreboard on the tasks page is redundant now that the home page shows the same stats, so the card and its view-model getters are removed. --- lib/main.dart | 2 - lib/ui/cards/scoreboard_card.dart | 163 ---------------------- lib/ui/pages/data_visualization_page.dart | 23 +-- lib/ui/pages/device_list_page.dart | 32 ++--- lib/ui/pages/task_list_page.dart | 21 +-- lib/view_models/tasklist_page_model.dart | 12 -- 6 files changed, 12 insertions(+), 241 deletions(-) delete mode 100644 lib/ui/cards/scoreboard_card.dart diff --git a/lib/main.dart b/lib/main.dart index 8f249a9b..cc06a7f1 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'; @@ -152,7 +151,6 @@ 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'; 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/pages/data_visualization_page.dart b/lib/ui/pages/data_visualization_page.dart index 6bd7a5a4..d4c56931 100644 --- a/lib/ui/pages/data_visualization_page.dart +++ b/lib/ui/pages/data_visualization_page.dart @@ -25,28 +25,7 @@ class _DataVisualizationPageState extends State { 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, - ), - ), - ], - ), - ), - ), - ), + CarpPageTitle(locale.translate('pages.data_viz.title')), Expanded( flex: 4, child: SingleChildScrollView( diff --git a/lib/ui/pages/device_list_page.dart b/lib/ui/pages/device_list_page.dart index 7d701888..9d845c16 100644 --- a/lib/ui/pages/device_list_page.dart +++ b/lib/ui/pages/device_list_page.dart @@ -50,28 +50,7 @@ class DeviceListPageState extends State { 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, - ), - ), - ], - ), - ), - ), - ), + CarpPageTitle(locale.translate('pages.devices.title')), Container( color: Colors.transparent, child: Padding( @@ -273,6 +252,15 @@ class DeviceListPageState extends State { if (!(await service.deviceManager.hasPermissions())) { if (service.type == HealthService.DEVICE_TYPE) { Navigator.push(context, 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(); } diff --git a/lib/ui/pages/task_list_page.dart b/lib/ui/pages/task_list_page.dart index 54cc55cc..b4c31825 100644 --- a/lib/ui/pages/task_list_page.dart +++ b/lib/ui/pages/task_list_page.dart @@ -106,26 +106,7 @@ class TaskListPageState extends State with TickerProviderStateMixi } 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), - ), + SliverToBoxAdapter(child: CarpPageTitle(locale.translate('pages.task_list.title'))), // Tab holder SliverPadding( padding: const EdgeInsets.only(top: 8, bottom: 24, left: 64, right: 64), diff --git a/lib/view_models/tasklist_page_model.dart b/lib/view_models/tasklist_page_model.dart index ab6dd28e..4e3aa68a 100644 --- a/lib/view_models/tasklist_page_model.dart +++ b/lib/view_models/tasklist_page_model.dart @@ -97,16 +97,4 @@ class TaskListPageViewModel extends ViewModel { /// 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; } From 76dbf52030aebf7002d179a6b4769d808f150dfc Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 10 Jul 2026 15:07:55 +0200 Subject: [PATCH 07/24] fix(ui): push full-screen pages on the root navigator The profile page and the device connect flows (Bluetooth and Health) were pushed onto the shell's nested navigator, leaving the bottom navigation bar visible. Push them on the root navigator so they cover the whole screen. --- lib/ui/pages/device_list_page.dart | 8 +++++--- lib/ui/widgets/carp_app_bar.dart | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/ui/pages/device_list_page.dart b/lib/ui/pages/device_list_page.dart index 9d845c16..4bedf270 100644 --- a/lib/ui/pages/device_list_page.dart +++ b/lib/ui/pages/device_list_page.dart @@ -251,7 +251,10 @@ 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 @@ -294,8 +297,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/widgets/carp_app_bar.dart b/lib/ui/widgets/carp_app_bar.dart index 6f50d8f3..e47e7f0e 100644 --- a/lib/ui/widgets/carp_app_bar.dart +++ b/lib/ui/widgets/carp_app_bar.dart @@ -41,7 +41,7 @@ class CarpAppBar extends StatelessWidget { icon: Icon(Icons.account_circle, color: Theme.of(context).primaryColor, size: 30), tooltip: 'Profile', onPressed: () { - Navigator.push(context, SlidePageRoute(ProfilePage(ProfilePageViewModel()))); + Navigator.of(context, rootNavigator: true).push(SlidePageRoute(ProfilePage(ProfilePageViewModel()))); }, ), ], From 20e769f346f322a77e374654cdc357d2e17ac507 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Thu, 16 Jul 2026 15:19:18 +0200 Subject: [PATCH 08/24] feat(ui): redesign home cards and add status and surveys sections Make the stat tiles compact (label + icon badge on top, value below, with a completed/total count on the tasks tile), slim the app-update banner down to a text row with a Get button, and add back a study status card showing the deployment state with its message. The deployment status is now refreshed once from the home view model - it was never fetched before, which kept the status card hidden and days-in-study at 0. The home page also reuses SurveyCard for a new Completed Surveys section; the card gets a showTitle flag so its internal header can be hidden under the page's section heading. --- lib/ui/cards/survey_card.dart | 15 ++- lib/ui/pages/home_page.dart | 162 ++++++++++++++++++++------- lib/view_models/home_page_model.dart | 22 ++++ 3 files changed, 154 insertions(+), 45 deletions(-) diff --git a/lib/ui/cards/survey_card.dart b/lib/ui/cards/survey_card.dart index f2f4314a..a87b5fc6 100644 --- a/lib/ui/cards/survey_card.dart +++ b/lib/ui/cards/survey_card.dart @@ -4,7 +4,11 @@ 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(); @@ -27,10 +31,11 @@ class _SurveyCardState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only(left: 10.0), - child: Text(locale.translate('cards.survey.title').toUpperCase(), style: fs16fw400ls1), - ), + if (widget.showTitle) + 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, diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index 638968eb..938efeb5 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -25,9 +25,11 @@ class HomePage extends StatelessWidget { const CarpPageTitle('UX Data collection study'), AppUpdateCard(model: model), ConnectionsStatusCard(model: model), + StudyStatusCard(model: model), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _statTile( @@ -39,11 +41,25 @@ class HomePage extends StatelessWidget { ), ), Expanded( - child: _statTile(colors, Icons.task_alt, colors.warningColor!, 'Task completed', '${model.taskCompleted}'), + child: _statTile( + colors, + Icons.task_alt, + colors.warningColor!, + 'Task completed', + '${model.taskCompleted}', + total: '${model.taskTotal}', + ), ), ], ), ), + if (model.surveys.tasksTable.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Completed Surveys', style: fs22fw700.copyWith(color: colors.grey900)), + ), + SurveyCard(model.surveys, showTitle: false), + ], Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text('Feeds', style: fs22fw700.copyWith(color: colors.grey900)), @@ -57,23 +73,37 @@ class HomePage extends StatelessWidget { ); } - Widget _statTile(CarpColors colors, IconData icon, Color iconColor, String label, String value) { + // Compact stat tile: label + icon badge on top, big value (with optional + // "/ total") below. + Widget _statTile(CarpColors colors, IconData icon, Color iconColor, String label, String value, {String? total}) { return StudiesMaterial( backgroundColor: colors.grey50!, child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration(color: iconColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10)), - child: Icon(icon, color: iconColor, size: 20), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: Text(label, style: fs14fw600.copyWith(color: colors.grey600))), + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration(color: iconColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10)), + child: Icon(icon, color: iconColor, size: 18), + ), + ], + ), + const SizedBox(height: 8), + Text.rich( + TextSpan( + text: value, + style: fs30fw800.copyWith(color: colors.grey900, fontSize: 28), + children: [ + if (total != null) TextSpan(text: ' / $total', style: fs14fw600.copyWith(color: colors.grey500)), + ], + ), ), - const SizedBox(height: 24), - Text(label, style: fs14fw600.copyWith(color: colors.grey600)), - const SizedBox(height: 4), - Text(value, style: fs30fw800.copyWith(color: colors.grey900)), ], ), ), @@ -123,50 +153,102 @@ class HomePage extends StatelessWidget { } /// Home banner shown only when a newer app version is available in the store. -/// Tapping opens the store; the X dismisses it for this session. -class AppUpdateCard extends StatefulWidget { +/// 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 - State createState() => _AppUpdateCardState(); + Widget build(BuildContext context) { + if (!model.appUpdateAvailable) return const SizedBox.shrink(); + final colors = Theme.of(context).extension()!; + final locale = RPLocalizations.of(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'), + ), + ], + ), + ), + ); + } } -class _AppUpdateCardState extends State { - bool _dismissed = false; +/// Home card showing the deployment status of the study (running, deploying, +/// stopped, ...) with its explanatory message. +class StudyStatusCard extends StatelessWidget { + final HomePageViewModel model; + const StudyStatusCard({required this.model, super.key}); + + static const Map _statusColors = { + StudyDeploymentStatusTypes.Invited: CACHET.DEPLOYMENT_INVITED, + StudyDeploymentStatusTypes.DeployingDevices: CACHET.DEPLOYMENT_DEPLOYING, + StudyDeploymentStatusTypes.Running: CACHET.DEPLOYMENT_RUNNING, + StudyDeploymentStatusTypes.Stopped: CACHET.DEPLOYMENT_STOPPED, + }; + + static const Map _statusLabels = { + StudyDeploymentStatusTypes.Invited: 'INVITED', + StudyDeploymentStatusTypes.DeployingDevices: 'DEPLOYING', + StudyDeploymentStatusTypes.Running: 'RUNNING', + StudyDeploymentStatusTypes.Stopped: 'STOPPED', + }; + + static const Map _statusMessages = { + StudyDeploymentStatusTypes.Invited: 'pages.about.status.invited.message', + StudyDeploymentStatusTypes.DeployingDevices: 'pages.about.status.deploying_devices.message', + StudyDeploymentStatusTypes.Running: 'pages.about.status.running.message', + StudyDeploymentStatusTypes.Stopped: 'pages.about.status.stopped.message', + }; @override Widget build(BuildContext context) { - if (!widget.model.appUpdateAvailable || _dismissed) return const SizedBox.shrink(); + final status = model.deploymentStatus; + if (status == null) return const SizedBox.shrink(); final colors = Theme.of(context).extension()!; final locale = RPLocalizations.of(context)!; + final accent = _statusColors[status] ?? colors.grey500!; return StudiesMaterial( backgroundColor: colors.grey50!, - child: InkWell( - onTap: widget.model.openAppStore, - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration(color: colors.primary!.withValues(alpha: 0.12), shape: BoxShape.circle), - child: Icon(Icons.info_outline, color: colors.primary), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - locale.translate('pages.about.app_update'), - style: fs16fw600.copyWith(color: colors.grey900), - ), - ), - IconButton( - icon: Icon(Icons.close, color: colors.grey500), - onPressed: () => setState(() => _dismissed = true), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Column( + children: [ + CircleAvatar(radius: 12, backgroundColor: accent), + const SizedBox(height: 4), + Text(_statusLabels[status] ?? '', style: fs12fw600.copyWith(color: accent)), + ], + ), + const SizedBox(width: 16), + Expanded( + child: Text( + locale.translate(_statusMessages[status] ?? ''), + style: fs14fw600.copyWith(color: colors.grey900), ), - ], - ), + ), + ], ), ), ); diff --git a/lib/view_models/home_page_model.dart b/lib/view_models/home_page_model.dart index 82cbab1b..38b78104 100644 --- a/lib/view_models/home_page_model.dart +++ b/lib/view_models/home_page_model.dart @@ -19,6 +19,9 @@ class HomePageViewModel extends ViewModel { SystemInfoService get _system => _systemInfoService ?? bloc.system; StudyService get _study => _studyService ?? bloc.study; + /// Per-survey completion counts for the "Completed Surveys" card. + final TaskCardViewModel surveys = TaskCardViewModel(AppTask.SURVEY_TYPE); + bool _healthConnectPromptPending = false; bool _appUpdateAvailable = false; List _connectionSources = const []; @@ -34,6 +37,13 @@ class HomePageViewModel extends ViewModel { /// The number of tasks completed so far. int get taskCompleted => AppTaskController().userTaskQueue.where((task) => task.state == UserTaskState.done).length; + /// The total number of tasks issued so far. + int get taskTotal => AppTaskController().userTaskQueue.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; @@ -66,6 +76,18 @@ class HomePageViewModel extends ViewModel { _userTaskSub = AppTaskController().userTaskEvents.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 and daysInStudy is 0. + 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 From 28f2ee241423ada29c767c3985042309a8787491 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 27 Jul 2026 13:04:30 +0200 Subject: [PATCH 09/24] build: bump CAMS packages and add share_plus and shimmer --- pubspec.lock | 36 ++++++++++++++++++++++++++++++------ pubspec.yaml | 6 ++++-- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 30bb8aa1..ec847edd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -317,18 +317,18 @@ packages: dependency: "direct main" description: name: carp_health_package - sha256: "5c362e97fde54d7bc7282cc64ba58d63fda904719ab97400d62d13458508450f" + sha256: "18e584a10c707e3582481396a119bbad9a942aad4628c166d134f6d04fb7a795" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "4.0.2" carp_mobile_sensing: dependency: "direct main" description: name: carp_mobile_sensing - sha256: e2b77cdb968678e9ed713637930d4861932ac83594ec7a6c36026a15bc149347 + sha256: e96a69d01adc7b84c17e52c01d6d47bcc0f8f9816edaf2c9a57b5f866d21448f url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.5" carp_movesense_package: dependency: "direct main" description: @@ -1792,10 +1792,10 @@ packages: dependency: transitive description: name: screen_state - sha256: "64a4583456f9fa91a8d4c681864384513b65590b0bc18fbc4b74c519c4b66c75" + sha256: ee9dd1d902bd0ce8af921f8cbbc88713280498528c5229ebdfb2138a5d70e333 url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.0.2" sensors_plus: dependency: transitive description: @@ -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 c226fee2..7f610bfb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,13 +13,13 @@ dependencies: carp_serializable: ^2.0.1 carp_core: ^2.1.2 - carp_mobile_sensing: ^2.1.2 + carp_mobile_sensing: ^2.1.5 carp_context_package: ^2.0.0 carp_connectivity_package: ^2.0.0 carp_survey_package: ^2.0.0 carp_audio_package: ^2.0.0 carp_polar_package: ^2.0.0 - carp_health_package: ^4.0.0 + carp_health_package: ^4.0.2 carp_movesense_package: ^2.0.0 carp_themes_package: ^0.0.5 @@ -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 From c7b2cebc38803d3b5538065a10a1e0f3375d141d Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 27 Jul 2026 13:04:38 +0200 Subject: [PATCH 10/24] feat(ui): redesign profile and study details pages Adds informed-consent download to the profile page, reorders the shell tabs, and pins the app to the light theme until the dark palette is done. --- assets/lang/da.json | 3 +- assets/lang/en.json | 3 +- assets/lang/es.json | 3 +- lib/carp_study_app.dart | 5 +- lib/main.dart | 4 +- lib/ui/cards/connections_status_card.dart | 41 +-- lib/ui/pages/app_shell.dart | 18 +- lib/ui/pages/profile_page.dart | 333 +++++++++++++--------- lib/ui/pages/study_details_page.dart | 296 +++++++------------ lib/ui/widgets/studies_material.dart | 47 +-- lib/view_models/profile_page_model.dart | 14 + 11 files changed, 364 insertions(+), 403 deletions(-) diff --git a/assets/lang/da.json b/assets/lang/da.json index 124138e3..7673b830 100644 --- a/assets/lang/da.json +++ b/assets/lang/da.json @@ -97,9 +97,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", diff --git a/assets/lang/en.json b/assets/lang/en.json index df9fd253..ac475f4c 100644 --- a/assets/lang/en.json +++ b/assets/lang/en.json @@ -111,8 +111,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", diff --git a/assets/lang/es.json b/assets/lang/es.json index b28a0909..ca879b28 100644 --- a/assets/lang/es.json +++ b/assets/lang/es.json @@ -58,9 +58,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", diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index 6c66f6cf..bccf5e47 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -247,7 +247,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, ); diff --git a/lib/main.dart b/lib/main.dart index cc06a7f1..e83965ac 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,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; @@ -170,7 +172,7 @@ void main() async { CarpDataManager.ensureInitialized(); await bloc.initialize(); - + app = const CarpStudyApp(); runApp(app); } diff --git a/lib/ui/cards/connections_status_card.dart b/lib/ui/cards/connections_status_card.dart index 9a2baf2b..6383dc34 100644 --- a/lib/ui/cards/connections_status_card.dart +++ b/lib/ui/cards/connections_status_card.dart @@ -29,22 +29,17 @@ class _ConnectionsStatusCardState extends State { final total = model.totalSourceCount; final active = model.activeSourceCount; - final (accent, icon, title, badge) = switch (model.connectionState) { - HomeConnectionState.all => (_green, Icons.sync, 'Connected & sending data', 'LIVE'), - HomeConnectionState.partial => (_amber, Icons.sync_problem, 'Partially connected', 'ACTION'), - HomeConnectionState.none => (_rose, Icons.sync_disabled, 'No devices connected', 'SETUP'), + 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 of $total sources active', + HomeConnectionState.partial => '$active sources active · ${total - active} inactive', HomeConnectionState.none => 'No sources active', }; - final hint = switch (model.connectionState) { - HomeConnectionState.all => 'tap to view', - HomeConnectionState.partial => 'tap to fix', - HomeConnectionState.none => 'tap to set up', - }; return StudiesMaterial( backgroundColor: colors.grey50!, @@ -58,6 +53,7 @@ class _ConnectionsStatusCardState extends State { child: Padding( padding: const EdgeInsets.all(16), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.all(12), @@ -69,15 +65,9 @@ class _ConnectionsStatusCardState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Flexible(child: Text(title, style: fs20fw700.copyWith(color: colors.grey900))), - const SizedBox(width: 8), - _badge(accent, badge), - ], - ), + Text(title, style: fs18fw700.copyWith(color: colors.grey900)), const SizedBox(height: 4), - Text('$sources • $hint', style: fs14fw600.copyWith(color: colors.grey600)), + Text(sources, style: fs14fw600.copyWith(color: colors.grey600)), ], ), ), @@ -140,19 +130,4 @@ class _ConnectionsStatusCardState extends State { ], ); } - - Widget _badge(Color accent, String label) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration(color: accent.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(100)), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.circle, size: 8, color: accent), - const SizedBox(width: 5), - Text(label, style: fs12fw600.copyWith(color: accent)), - ], - ), - ); - } } diff --git a/lib/ui/pages/app_shell.dart b/lib/ui/pages/app_shell.dart index 438d234e..491817ab 100644 --- a/lib/ui/pages/app_shell.dart +++ b/lib/ui/pages/app_shell.dart @@ -60,16 +60,16 @@ class CarpAppShellState extends State { 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.statistics'), - activeIcon: const Icon(Icons.leaderboard), - ), 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), @@ -85,10 +85,10 @@ class CarpAppShellState extends State { if (location.startsWith(TaskListPage.route)) { return 1; } - if (location.startsWith(DataVisualizationPage.route)) { + if (location.startsWith(DeviceListPage.route)) { return 2; } - if (location.startsWith(DeviceListPage.route)) { + if (location.startsWith(DataVisualizationPage.route)) { return 3; } return -1; @@ -103,10 +103,10 @@ class CarpAppShellState extends State { context.go(TaskListPage.route); break; case 2: - context.go(DataVisualizationPage.route); + context.go(DeviceListPage.route); break; case 3: - context.go(DeviceListPage.route); + context.go(DataVisualizationPage.route); break; case -1: context.go(CarpAppState.homeRoute); diff --git a/lib/ui/pages/profile_page.dart b/lib/ui/pages/profile_page.dart index 67cdb267..e1e06ea6 100644 --- a/lib/ui/pages/profile_page.dart +++ b/lib/ui/pages/profile_page.dart @@ -24,187 +24,238 @@ 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: () => Navigator.of(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, + 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 {} - }, - ), - _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_id'), + widget.model.participantId, + copyable: true, ), + _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 +269,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( diff --git a/lib/ui/pages/study_details_page.dart b/lib/ui/pages/study_details_page.dart index a8c8453c..9271f366 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,124 @@ 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), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: double.infinity, + height: 180, + child: FittedBox(fit: BoxFit.cover, clipBehavior: Clip.hardEdge, child: model.image), ), ), + const SizedBox(height: 16), + Text(locale.translate(model.title), style: fs18fw700.copyWith(color: colors.grey900)), + const SizedBox(height: 8), Text( - locale.translate('widgets.study_card.participant_role'), - style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900), + locale.translate(model.description), + style: fs14fw600.copyWith(color: colors.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), - ), - ), - 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: 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/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/profile_page_model.dart b/lib/view_models/profile_page_model.dart index 24a2f515..2d90bba1 100644 --- a/lib/view_models/profile_page_model.dart +++ b/lib/view_models/profile_page_model.dart @@ -46,4 +46,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; + } + } } From ffb175f60b1711f82881791ba1e39336f1b8108d Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 27 Jul 2026 13:04:45 +0200 Subject: [PATCH 11/24] feat(home): show backend messages in the feeds section --- lib/ui/pages/home_page.dart | 401 ++++++++++++++++++--------- lib/view_models/home_page_model.dart | 53 +++- 2 files changed, 318 insertions(+), 136 deletions(-) diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index 938efeb5..3f7f08a9 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -1,7 +1,6 @@ part of carp_study_app; /// The redesigned home page (design 2.0) - the landing tab of the app shell. -// ponytail: hardcoded content; wire study title and feeds later. class HomePage extends StatelessWidget { static const String route = '/home'; final HomePageViewModel model; @@ -22,50 +21,32 @@ class HomePage extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: CarpAppBar(hasProfileIcon: true), ), - const CarpPageTitle('UX Data collection study'), - AppUpdateCard(model: model), - ConnectionsStatusCard(model: model), - StudyStatusCard(model: model), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _statTile( - colors, - Icons.calendar_today_outlined, - colors.primary!, - 'Days in Study', - '${model.daysInStudy}', - ), - ), - Expanded( - child: _statTile( - colors, - Icons.task_alt, - colors.warningColor!, - 'Task completed', - '${model.taskCompleted}', - total: '${model.taskTotal}', - ), - ), - ], - ), - ), - if (model.surveys.tasksTable.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Completed Surveys', style: fs22fw700.copyWith(color: colors.grey900)), + if (!model.isLoaded) + _skeleton(colors) + else ...[ + AppUpdateCard(model: model), + StudyAboutCard(model: model), + _sectionHeader(colors, 'Connections'), + ConnectionsStatusCard(model: model), + _sectionHeader(colors, 'Your progress'), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _activeDaysTile(context, colors)), + Expanded(child: _taskStatusTile(context, colors)), + ], + ), ), - SurveyCard(model.surveys, showTitle: false), + if (model.surveys.tasksTable.isNotEmpty) ...[ + _sectionHeader(colors, 'Completed Surveys'), + SurveyCard(model.surveys, showTitle: false), + ], + if (model.messages.isNotEmpty) ...[ + _sectionHeader(colors, 'Feeds'), + for (final message in model.messages) _feedCard(context, colors, message), + ], ], - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Feeds', style: fs22fw700.copyWith(color: colors.grey900)), - ), - _feedCard(colors, 'Connect Polar Strap', "Sync your heart rate sensor for today's session to ensure data accuracy."), - _feedCard(colors, 'Health Issues', "Sync your heart rate sensor for today's session."), ], ), ), @@ -73,11 +54,128 @@ class HomePage extends StatelessWidget { ); } - // Compact stat tile: label + icon badge on top, big value (with optional - // "/ total") below. - Widget _statTile(CarpColors colors, IconData icon, Color iconColor, String label, String value, {String? total}) { + /// 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), + ], + ), + ); + } + + 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)), + ); + } + + /// "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(DataVisualizationPage.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( @@ -87,21 +185,20 @@ class HomePage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: Text(label, style: fs14fw600.copyWith(color: colors.grey600))), - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration(color: iconColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10)), - child: Icon(icon, color: iconColor, size: 18), - ), + _iconBadge(icon, iconColor), ], ), const SizedBox(height: 8), - Text.rich( - TextSpan( - text: value, - style: fs30fw800.copyWith(color: colors.grey900, fontSize: 28), - children: [ - if (total != null) TextSpan(text: ' / $total', style: fs14fw600.copyWith(color: colors.grey500)), - ], + 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), ), ), ], @@ -110,44 +207,58 @@ class HomePage extends StatelessWidget { ); } - Widget _feedCard(CarpColors colors, String title, String body) { + /// 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: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: Text(title, style: fs20fw700.copyWith(color: colors.grey900))), - _circleIcon(Icons.campaign, colors.primary!), + 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)), ], - ), - const SizedBox(height: 4), - Text('Subtitle', style: fs14fw600.copyWith(color: colors.grey600)), - const SizedBox(height: 8), - Text(body, 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('Today', style: fs12fw600.copyWith(color: colors.grey500)), + 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)), + ], + ), + ], + ), ), ), ); } - Widget _circleIcon(IconData icon, Color color) { - return Material( - color: color, - shape: const CircleBorder(), - child: Padding(padding: const EdgeInsets.all(8), child: Icon(icon, color: Colors.white, size: 20)), + // 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), ); } } @@ -193,59 +304,75 @@ class AppUpdateCard extends StatelessWidget { } } -/// Home card showing the deployment status of the study (running, deploying, -/// stopped, ...) with its explanatory message. -class StudyStatusCard extends StatelessWidget { +/// 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 StudyStatusCard({required this.model, super.key}); + const StudyAboutCard({required this.model, super.key}); - static const Map _statusColors = { - StudyDeploymentStatusTypes.Invited: CACHET.DEPLOYMENT_INVITED, - StudyDeploymentStatusTypes.DeployingDevices: CACHET.DEPLOYMENT_DEPLOYING, - StudyDeploymentStatusTypes.Running: CACHET.DEPLOYMENT_RUNNING, - StudyDeploymentStatusTypes.Stopped: CACHET.DEPLOYMENT_STOPPED, - }; - - static const Map _statusLabels = { - StudyDeploymentStatusTypes.Invited: 'INVITED', - StudyDeploymentStatusTypes.DeployingDevices: 'DEPLOYING', - StudyDeploymentStatusTypes.Running: 'RUNNING', - StudyDeploymentStatusTypes.Stopped: 'STOPPED', - }; - - static const Map _statusMessages = { - StudyDeploymentStatusTypes.Invited: 'pages.about.status.invited.message', - StudyDeploymentStatusTypes.DeployingDevices: 'pages.about.status.deploying_devices.message', - StudyDeploymentStatusTypes.Running: 'pages.about.status.running.message', - StudyDeploymentStatusTypes.Stopped: 'pages.about.status.stopped.message', + 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 status = model.deploymentStatus; - if (status == null) return const SizedBox.shrink(); final colors = Theme.of(context).extension()!; + final primary = colors.primary!; final locale = RPLocalizations.of(context)!; - final accent = _statusColors[status] ?? colors.grey500!; return StudiesMaterial( - backgroundColor: colors.grey50!, - child: Padding( + child: Container( + width: double.infinity, padding: const EdgeInsets.all(16), - child: Row( + 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: [ - Column( - children: [ - CircleAvatar(radius: 12, backgroundColor: accent), - const SizedBox(height: 4), - Text(_statusLabels[status] ?? '', style: fs12fw600.copyWith(color: accent)), - ], + // 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(width: 16), - Expanded( - child: Text( - locale.translate(_statusMessages[status] ?? ''), - style: fs14fw600.copyWith(color: colors.grey900), + 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), + ], ), ), ], @@ -253,4 +380,24 @@ class StudyStatusCard extends StatelessWidget { ), ); } + + // 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/view_models/home_page_model.dart b/lib/view_models/home_page_model.dart index 38b78104..8d9b8fb1 100644 --- a/lib/view_models/home_page_model.dart +++ b/lib/view_models/home_page_model.dart @@ -10,14 +10,17 @@ enum HomeConnectionState { all, partial, none } /// the one-shot Health Connect install prompt. The UI reads these and rebuilds /// via [ListenableBuilder]; it never touches the services or streams directly. class HomePageViewModel extends ViewModel { - HomePageViewModel({SystemInfoService? systemInfoService, StudyService? studyService}) + HomePageViewModel({SystemInfoService? systemInfoService, StudyService? studyService, MessageService? messageService}) : _systemInfoService = systemInfoService, - _studyService = studyService; + _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; /// Per-survey completion counts for the "Completed Surveys" card. final TaskCardViewModel surveys = TaskCardViewModel(AppTask.SURVEY_TYPE); @@ -27,18 +30,48 @@ class HomePageViewModel extends ViewModel { List _connectionSources = const []; final List> _deviceSubs = []; StreamSubscription? _userTaskSub; + StreamSubscription? _messageSub; bool _blocAttached = false; - /// The number of days the user has been part of the study. - int get daysInStudy => (_study.cachedDeploymentStatus != null) - ? DateTime.now().difference(_study.cachedDeploymentStatus!.createdOn).inDays - : 0; + /// 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 total number of tasks issued so far. - int get taskTotal => AppTaskController().userTaskQueue.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 => @@ -74,13 +107,14 @@ class HomePageViewModel extends ViewModel { _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 and daysInStudy is 0. + // status card stays hidden. Future _refreshDeploymentStatus() async { try { await _study.refreshDeploymentStatus(); @@ -148,6 +182,7 @@ class HomePageViewModel extends ViewModel { if (_blocAttached) bloc.removeListener(_syncSources); _cancelDeviceSubs(); _userTaskSub?.cancel(); + _messageSub?.cancel(); super.dispose(); } } From 11cdb6e9efa306a0c7db548d29df64291c1fd384 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 27 Jul 2026 13:04:45 +0200 Subject: [PATCH 12/24] fix(invitations): sort invitations and log them for debugging CAWS returns invitations in no particular order, so the list reshuffled on every pull-to-refresh. Sort by study name, deployment id as tie-breaker. Logs are tagged INVITATIONS to trace duplicate entries. --- lib/services/auth_service.dart | 41 +++++++++++++++++++-- lib/ui/pages/invitation_list_page.dart | 4 ++ lib/view_models/invitations_view_model.dart | 6 +-- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 49bb86ca..25c72d45 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -39,11 +39,46 @@ 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); + }); + _logInvitations(all, _invitations); + return _invitations; + } + + /// Dump the identity of every invitation CAWS returned, so entries that look + /// like duplicates in the UI (same study name) can be told apart by their + /// deployment / participant / role ids. Filter device logs with + /// `flutter logs | grep INVITATIONS`. + void _logInvitations(List all, List kept) { + logApp('INVITATIONS - backend returned ${all.length}, kept ${kept.length} after the smartphone filter'); + for (final (index, invitation) in all.indexed) { + logApp( + 'INVITATIONS - [$index] ${kept.contains(invitation) ? 'KEPT ' : 'DROPPED'} ' + 'name="${invitation.invitation.name}" ' + 'studyId=${invitation.studyId} ' + 'deploymentId=${invitation.studyDeploymentId} ' + 'participantId=${invitation.participantId} ' + 'roles=${invitation.participation.assignedRoles.roleNames} ' + 'devices=${invitation.assignedDevices?.map((d) => '${d.device.roleName}(${d.device.runtimeType})').toList()}', + ); + } + // Same deployment twice means CAWS sent two participations of one deployment; + // distinct deployments with the same name means the study was deployed twice. + final deployments = all.map((invitation) => invitation.studyDeploymentId).toList(); + logApp( + 'INVITATIONS - ${deployments.toSet().length} distinct deployment(s) in ${deployments.length} invitation(s), ' + '${all.map((invitation) => invitation.participantId).toSet().length} distinct participant id(s)', + ); } /// Authenticate using a web view. diff --git a/lib/ui/pages/invitation_list_page.dart b/lib/ui/pages/invitation_list_page.dart index f21bda27..9933992f 100644 --- a/lib/ui/pages/invitation_list_page.dart +++ b/lib/ui/pages/invitation_list_page.dart @@ -31,6 +31,10 @@ class _InvitationListPageState extends State { child = const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator())); } else { final invitations = widget.model.invitations; + logApp( + 'INVITATIONS - rendering ${invitations.length} card(s): ' + '${invitations.map((i) => '${i.invitation.name}/${i.studyDeploymentId}').toList()}', + ); child = SliverFixedExtentList( itemExtent: 150, delegate: SliverChildBuilderDelegate((context, index) { diff --git a/lib/view_models/invitations_view_model.dart b/lib/view_models/invitations_view_model.dart index a314d99c..5ee243d1 100644 --- a/lib/view_models/invitations_view_model.dart +++ b/lib/view_models/invitations_view_model.dart @@ -30,17 +30,17 @@ class InvitationsViewModel extends ViewModel { /// 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'); + logApp('INVITATIONS - InvitationsViewModel.loadInvitations() START'); try { _invitations = await _auth.getInvitations(); _error = null; logApp( - 'InvitationsViewModel.loadInvitations() DONE - ${_invitations!.length} invitation(s): ' + 'INVITATIONS - 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'); + logApp('INVITATIONS - InvitationsViewModel.loadInvitations() FAILED - $error'); _error = error; } notifyListeners(); From 31e98a4e3ba526d820c867f9f801095282a5c3b9 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 28 Jul 2026 09:06:11 +0200 Subject: [PATCH 13/24] feat(messages): notify the user about new announcements Messages are polled, not pushed, so a new announcement could sit unseen until the user happened to open the study page. Fire a local notification for every message id that was not in the previous refresh; the first refresh only records the backlog so opening the app stays quiet. --- lib/services/message_service.dart | 33 +++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/services/message_service.dart b/lib/services/message_service.dart index 0ce3fb4e..139cb7c3 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(); From 39a3ca9dba6a3231136df0fa2a2a1c5763700c0f Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 28 Jul 2026 09:06:19 +0200 Subject: [PATCH 14/24] fix(invitations): identify invitations by their deployment id A participant invited to two deployments of the same study shares one participant id across both, so keying the detail route on it made the second card open the first deployment. Use the study deployment id, the only id unique per invitation. Leaving a study no longer clears the invitation list either - invitations belong to the account, not the study, and clearing them left the list page spinning forever on a state nothing reloaded. --- lib/ui/pages/invitation_details_page.dart | 8 ++++++- lib/ui/pages/invitation_list_page.dart | 6 +---- lib/view_models/invitations_view_model.dart | 26 +++++++++++---------- lib/view_models/view_model.dart | 6 +++-- test/view_models_test.dart | 21 +++++++++++++---- 5 files changed, 42 insertions(+), 25 deletions(-) diff --git a/lib/ui/pages/invitation_details_page.dart b/lib/ui/pages/invitation_details_page.dart index a650b21d..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,6 +137,9 @@ 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(HomePage.route); diff --git a/lib/ui/pages/invitation_list_page.dart b/lib/ui/pages/invitation_list_page.dart index 9933992f..d0a0c770 100644 --- a/lib/ui/pages/invitation_list_page.dart +++ b/lib/ui/pages/invitation_list_page.dart @@ -31,10 +31,6 @@ class _InvitationListPageState extends State { child = const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator())); } else { final invitations = widget.model.invitations; - logApp( - 'INVITATIONS - rendering ${invitations.length} card(s): ' - '${invitations.map((i) => '${i.invitation.name}/${i.studyDeploymentId}').toList()}', - ); child = SliverFixedExtentList( itemExtent: 150, delegate: SliverChildBuilderDelegate((context, index) { @@ -118,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/view_models/invitations_view_model.dart b/lib/view_models/invitations_view_model.dart index 5ee243d1..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('INVITATIONS - InvitationsViewModel.loadInvitations() START'); try { _invitations = await _auth.getInvitations(); _error = null; - logApp( - 'INVITATIONS - InvitationsViewModel.loadInvitations() DONE - ${_invitations!.length} invitation(s): ' - '${_invitations!.map((i) => i.studyDeploymentId).toList()}', - ); } catch (error) { warning('$runtimeType - Could not load invitations - $error'); - logApp('INVITATIONS - 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/view_model.dart b/lib/view_models/view_model.dart index 96ab0168..1a0adfb0 100644 --- a/lib/view_models/view_model.dart +++ b/lib/view_models/view_model.dart @@ -193,7 +193,10 @@ 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(); @@ -243,7 +246,6 @@ class AppViewModel extends ViewModel { _devicesPageViewModel.clear(); _profilePageViewModel.clear(); - _invitationsListViewModel.clear(); _informedConsentViewModel.clear(); super.clear(); diff --git a/test/view_models_test.dart b/test/view_models_test.dart index e26ca071..e70465b1 100644 --- a/test/view_models_test.dart +++ b/test/view_models_test.dart @@ -165,7 +165,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 +179,25 @@ 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 { From c1a6390a5fcbbf3dd57c55e75692c6f44f7c76d3 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 28 Jul 2026 09:06:36 +0200 Subject: [PATCH 15/24] refactor(consent): gate informed consent in the app shell Consent was a router redirect target, so it was always the only route on the stack: research_package pops that route itself on cancel, which emptied go_router and threw. It is now pushed by the app shell, whose mounting is exactly the precondition - the router only builds it for a signed-in user with a study - so a pop always has somewhere to land. Drops the cached tri-state consent status, the consent listener that configured the study as a side effect, and the redirect rule. The view model now owns the local flag and the accept / reject decision; the service is left as a pure CAWS connector. --- integration_test/join_study_flow_test.dart | 4 +- lib/carp_study_app.dart | 48 +++--- lib/services/consent_service.dart | 64 ++----- lib/ui/pages/app_shell.dart | 116 ------------- lib/ui/pages/carp_app_shell.dart | 161 ++++++++++++++++++ lib/ui/pages/informed_consent_page.dart | 71 ++------ .../informed_consent_page_model.dart | 35 ---- .../informed_consent_view_model.dart | 80 +++++++++ test/services_test.dart | 86 ++++++---- 9 files changed, 353 insertions(+), 312 deletions(-) delete mode 100644 lib/ui/pages/app_shell.dart create mode 100644 lib/ui/pages/carp_app_shell.dart delete mode 100644 lib/view_models/informed_consent_page_model.dart create mode 100644 lib/view_models/informed_consent_view_model.dart 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/carp_study_app.dart b/lib/carp_study_app.dart index bccf5e47..d66aebe1 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,27 +43,22 @@ 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) => - CarpAppShell(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. @@ -113,17 +106,19 @@ class CarpAppState extends State { transitionsBuilder: bottomNavigationBarAnimation, ), ), - GoRoute( - path: ProfilePage.route, - parentNavigatorKey: _shellNavigatorKey, - pageBuilder: (context, state) => CustomTransitionPage( - key: state.pageKey, - 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( @@ -285,3 +280,14 @@ Widget bottomNavigationBarAnimation( 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, +) { + 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/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/ui/pages/app_shell.dart b/lib/ui/pages/app_shell.dart deleted file mode 100644 index 491817ab..00000000 --- a/lib/ui/pages/app_shell.dart +++ /dev/null @@ -1,116 +0,0 @@ -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). -/// -/// All setup orchestration (consent gating, study configuration, starting -/// sensing) is owned by the [AppBloc] and the router redirect - not this page. -class CarpAppShell extends StatefulWidget { - final HomePageViewModel model; - final Widget child; - const CarpAppShell({required this.model, required this.child, super.key}); - - @override - CarpAppShellState createState() => CarpAppShellState(); -} - -class CarpAppShellState extends State { - @override - void initState() { - super.initState(); - widget.model.addListener(_onModelChanged); - } - - @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), - bottomNavigationBar: 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(DataVisualizationPage.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(DataVisualizationPage.route); - break; - case -1: - context.go(CarpAppState.homeRoute); - break; - } - } -} diff --git a/lib/ui/pages/carp_app_shell.dart b/lib/ui/pages/carp_app_shell.dart new file mode 100644 index 00000000..d4e0d75b --- /dev/null +++ b/lib/ui/pages/carp_app_shell.dart @@ -0,0 +1,161 @@ +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); + // Deferred one frame because pushing a route during initState/build throws, + // and because reading Localizations is illegal in initState. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_gateInformedConsent()); + }); + } + + /// 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 _gateInformedConsent() 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 takes seconds, and the home + // page has a skeleton for that - so don't hold the user on a blank frame. + unawaited(bloc.tryConfigureStudy()); + } catch (error) { + // Nothing here may escape: this runs unawaited from a frame callback, so + // a throw would surface as an unhandled async error and nothing else. + // ponytail: no retry affordance - the home page stays a skeleton until + // the app is reopened. Add one if offline starts turn out to be common. + 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(DataVisualizationPage.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(DataVisualizationPage.route); + break; + case -1: + context.go(CarpAppState.homeRoute); + break; + } + } +} diff --git a/lib/ui/pages/informed_consent_page.dart b/lib/ui/pages/informed_consent_page.dart index d6662e8e..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 { +/// 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/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/test/services_test.dart b/test/services_test.dart index 632828a5..fdac55c5 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,20 @@ 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 +260,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); }); }); From 2f299dbb5355b1f2ee329ad3b3d71fb06b6d6d13 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 28 Jul 2026 09:06:45 +0200 Subject: [PATCH 16/24] refactor(ui): navigate to the profile page with go_router The app bar pushed the profile imperatively onto the root navigator with its own PageRoute, bypassing the router and building a throwaway view model instead of the shared one. Route to it instead, moved out of the shell since profile is not a bottom-nav tab. --- lib/ui/pages/profile_page.dart | 18 +----------------- lib/ui/widgets/carp_app_bar.dart | 4 +--- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/lib/ui/pages/profile_page.dart b/lib/ui/pages/profile_page.dart index e1e06ea6..7ded62d0 100644 --- a/lib/ui/pages/profile_page.dart +++ b/lib/ui/pages/profile_page.dart @@ -41,7 +41,7 @@ class ProfilePageState extends State { IconButton( icon: Icon(Icons.close, color: colors.primary, size: 28), tooltip: locale.translate('Back'), - onPressed: () => Navigator.of(context).pop(), + onPressed: () => context.pop(), ), ], ), @@ -360,19 +360,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/widgets/carp_app_bar.dart b/lib/ui/widgets/carp_app_bar.dart index e47e7f0e..34f09132 100644 --- a/lib/ui/widgets/carp_app_bar.dart +++ b/lib/ui/widgets/carp_app_bar.dart @@ -40,9 +40,7 @@ class CarpAppBar extends StatelessWidget { IconButton( icon: Icon(Icons.account_circle, color: Theme.of(context).primaryColor, size: 30), tooltip: 'Profile', - onPressed: () { - Navigator.of(context, rootNavigator: true).push(SlidePageRoute(ProfilePage(ProfilePageViewModel()))); - }, + onPressed: () => context.push(ProfilePage.route), ), ], ), From 83458421b2985d50f6818bd25e559f68e736227c Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 28 Jul 2026 09:06:59 +0200 Subject: [PATCH 17/24] refactor: group core and helper files and trim app logging Introduces lib/core (bloc, sensing, CAWS backend, resource managers) and lib/helpers (config, logging, utils), retiring lib/blocs. View model files are renamed to *_view_model.dart and each documents, in the same shape, which page it backs, the state it holds, and its responsibility. Drops logAppState and its state snapshot, which dumped seventeen lines on every router redirect, along with the temporary timing logs. --- lib/blocs/app_log.dart | 67 ------------------- lib/{blocs => core}/app_bloc.dart | 37 ---------- lib/{data => core}/carp_backend.dart | 0 .../resource_manager_factory.dart | 0 lib/{blocs => core}/sensing.dart | 0 lib/{blocs => helpers}/app_config.dart | 0 lib/helpers/app_log.dart | 11 +++ lib/{blocs => helpers}/util.dart | 0 lib/main.dart | 35 +++++----- lib/services/auth_service.dart | 27 -------- lib/services/study_service.dart | 2 - lib/ui/pages/login_page.dart | 4 -- ...art => data_visualization_view_model.dart} | 6 ++ ...iew_models.dart => device_view_model.dart} | 15 +++-- ...e_page_model.dart => home_view_model.dart} | 12 ++-- ..._page_model.dart => login_view_model.dart} | 9 +-- ....dart => participant_data_view_model.dart} | 5 ++ ...age_model.dart => profile_view_model.dart} | 6 ++ ..._page_model.dart => study_view_model.dart} | 8 ++- ...e_model.dart => task_list_view_model.dart} | 7 +- 20 files changed, 81 insertions(+), 170 deletions(-) delete mode 100644 lib/blocs/app_log.dart rename lib/{blocs => core}/app_bloc.dart (81%) rename lib/{data => core}/carp_backend.dart (100%) rename lib/{services => core}/resource_manager_factory.dart (100%) rename lib/{blocs => core}/sensing.dart (100%) rename lib/{blocs => helpers}/app_config.dart (100%) create mode 100644 lib/helpers/app_log.dart rename lib/{blocs => helpers}/util.dart (100%) rename lib/view_models/{data_visualization_page_model.dart => data_visualization_view_model.dart} (95%) rename lib/view_models/{device_view_models.dart => device_view_model.dart} (95%) rename lib/view_models/{home_page_model.dart => home_view_model.dart} (95%) rename lib/view_models/{login_page_model.dart => login_view_model.dart} (87%) rename lib/view_models/{participant_data_page_model.dart => participant_data_view_model.dart} (92%) rename lib/view_models/{profile_page_model.dart => profile_view_model.dart} (93%) rename lib/view_models/{study_page_model.dart => study_view_model.dart} (94%) rename lib/view_models/{tasklist_page_model.dart => task_list_view_model.dart} (93%) 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/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 100% rename from lib/blocs/app_config.dart rename to lib/helpers/app_config.dart 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 100% rename from lib/blocs/util.dart rename to lib/helpers/util.dart diff --git a/lib/main.dart b/lib/main.dart index e83965ac..60d10715 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -60,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'; @@ -74,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/data_visualization_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'; @@ -102,7 +103,7 @@ part 'view_models/user_tasks.dart'; part 'carp_study_app.dart'; part 'ui/pages/informed_consent_page.dart'; -part 'ui/pages/app_shell.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'; diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 25c72d45..a2dcdf5f 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -51,36 +51,9 @@ class AuthService { final byName = a.invitation.name.compareTo(b.invitation.name); return byName != 0 ? byName : a.studyDeploymentId.compareTo(b.studyDeploymentId); }); - _logInvitations(all, _invitations); return _invitations; } - /// Dump the identity of every invitation CAWS returned, so entries that look - /// like duplicates in the UI (same study name) can be told apart by their - /// deployment / participant / role ids. Filter device logs with - /// `flutter logs | grep INVITATIONS`. - void _logInvitations(List all, List kept) { - logApp('INVITATIONS - backend returned ${all.length}, kept ${kept.length} after the smartphone filter'); - for (final (index, invitation) in all.indexed) { - logApp( - 'INVITATIONS - [$index] ${kept.contains(invitation) ? 'KEPT ' : 'DROPPED'} ' - 'name="${invitation.invitation.name}" ' - 'studyId=${invitation.studyId} ' - 'deploymentId=${invitation.studyDeploymentId} ' - 'participantId=${invitation.participantId} ' - 'roles=${invitation.participation.assignedRoles.roleNames} ' - 'devices=${invitation.assignedDevices?.map((d) => '${d.device.roleName}(${d.device.runtimeType})').toList()}', - ); - } - // Same deployment twice means CAWS sent two participations of one deployment; - // distinct deployments with the same name means the study was deployed twice. - final deployments = all.map((invitation) => invitation.studyDeploymentId).toList(); - logApp( - 'INVITATIONS - ${deployments.toSet().length} distinct deployment(s) in ${deployments.length} invitation(s), ' - '${all.map((invitation) => invitation.participantId).toSet().length} distinct participant id(s)', - ); - } - /// Authenticate using a web view. Future authenticate() => _backend.authenticate(); 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/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/view_models/data_visualization_page_model.dart b/lib/view_models/data_visualization_view_model.dart similarity index 95% rename from lib/view_models/data_visualization_page_model.dart rename to lib/view_models/data_visualization_view_model.dart index 7229918a..3098b795 100644 --- a/lib/view_models/data_visualization_page_model.dart +++ b/lib/view_models/data_visualization_view_model.dart @@ -1,5 +1,11 @@ part of carp_study_app; +/// View model for [DataVisualizationPage]. +/// +/// State: which measures the deployment collects, so only the cards backed by +/// real data are shown. +/// +/// Computed once on [init] from the deployment - the cards own their own data. class DataVisualizationPageViewModel extends ViewModel { DataVisualizationPageViewModel({StudyService? studyService}) : _studyService = studyService; diff --git a/lib/view_models/device_view_models.dart b/lib/view_models/device_view_model.dart similarity index 95% rename from lib/view_models/device_view_models.dart rename to lib/view_models/device_view_model.dart index f06802f4..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,10 +32,12 @@ 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(); diff --git a/lib/view_models/home_page_model.dart b/lib/view_models/home_view_model.dart similarity index 95% rename from lib/view_models/home_page_model.dart rename to lib/view_models/home_view_model.dart index 8d9b8fb1..f6daa8be 100644 --- a/lib/view_models/home_page_model.dart +++ b/lib/view_models/home_view_model.dart @@ -3,12 +3,14 @@ part of carp_study_app; /// The 3-state connection summary shown on the home page. enum HomeConnectionState { all, partial, none } -/// The view model for the [HomePage]. +/// View model for [HomePage] and [CarpAppShell]. /// -/// Owns the home page's service-backed, reactive data: the connection summary -/// (deployment devices minus the phone), whether an app update is available, and -/// the one-shot Health Connect install prompt. The UI reads these and rebuilds -/// via [ListenableBuilder]; it never touches the services or streams directly. +/// 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, 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 93% rename from lib/view_models/profile_page_model.dart rename to lib/view_models/profile_view_model.dart index 2d90bba1..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, 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 93% rename from lib/view_models/tasklist_page_model.dart rename to lib/view_models/task_list_view_model.dart index 4e3aa68a..17cd48e0 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; From 4430a55c51761c8e4cf6c9d86747f600145982d7 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 28 Jul 2026 09:26:53 +0200 Subject: [PATCH 18/24] fix: remove some comments --- lib/ui/pages/carp_app_shell.dart | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/lib/ui/pages/carp_app_shell.dart b/lib/ui/pages/carp_app_shell.dart index d4e0d75b..a8db6692 100644 --- a/lib/ui/pages/carp_app_shell.dart +++ b/lib/ui/pages/carp_app_shell.dart @@ -22,17 +22,15 @@ class CarpAppShellState extends State { void initState() { super.initState(); widget.model.addListener(_onModelChanged); - // Deferred one frame because pushing a route during initState/build throws, - // and because reading Localizations is illegal in initState. WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) unawaited(_gateInformedConsent()); + 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 _gateInformedConsent() async { + Future _isInformedConsentAccepted() async { try { final status = await widget.consentModel.hasBeenAccepted(); if (!mounted) return; @@ -45,14 +43,9 @@ class CarpAppShellState extends State { return; } } - // Deploying the study and starting sensing takes seconds, and the home - // page has a skeleton for that - so don't hold the user on a blank frame. + // Deploying the study and starting sensing unawaited(bloc.tryConfigureStudy()); } catch (error) { - // Nothing here may escape: this runs unawaited from a frame callback, so - // a throw would surface as an unhandled async error and nothing else. - // ponytail: no retry affordance - the home page stays a skeleton until - // the app is reopened. Add one if offline starts turn out to be common. warning('$runtimeType - could not resolve informed consent - $error'); } } From 7150d19b12a837bbdc036a268d65ad60f83a5051 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 28 Jul 2026 10:22:22 +0200 Subject: [PATCH 19/24] fix: dart format --- lib/carp_study_app.dart | 6 +--- lib/main.dart | 2 +- lib/services/auth_service.dart | 4 ++- lib/services/message_service.dart | 2 +- lib/ui/cards/connections_status_card.dart | 9 +++--- lib/ui/pages/home_page.dart | 34 +++++++++++++++-------- lib/ui/pages/profile_page.dart | 30 ++++++++++++++++---- lib/ui/pages/study_details_page.dart | 23 ++++++++++----- lib/view_models/home_view_model.dart | 8 +++--- test/services_test.dart | 6 ++-- test/view_models_test.dart | 6 ++-- 11 files changed, 84 insertions(+), 46 deletions(-) diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index d66aebe1..3d791174 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -83,11 +83,7 @@ class CarpAppState extends State { ), // 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: StudyPage.route, parentNavigatorKey: _shellNavigatorKey, redirect: (_, _) => HomePage.route), GoRoute( path: DataVisualizationPage.route, parentNavigatorKey: _shellNavigatorKey, diff --git a/lib/main.dart b/lib/main.dart index 60d10715..5f0400e8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -173,7 +173,7 @@ void main() async { CarpDataManager.ensureInitialized(); await bloc.initialize(); - + app = const CarpStudyApp(); runApp(app); } diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index a2dcdf5f..a6f6bf0e 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -46,7 +46,9 @@ class AuthService { Future> getInvitations() async { final all = await _backend.getInvitations(); _invitations = - all.where((invitation) => invitation.assignedDevices?.any((device) => device.device is! Smartphone) != true).toList() + 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); diff --git a/lib/services/message_service.dart b/lib/services/message_service.dart index 139cb7c3..392458c7 100644 --- a/lib/services/message_service.dart +++ b/lib/services/message_service.dart @@ -61,7 +61,7 @@ class MessageService { } /// Notify the user about messages that were not in the previous [refresh]. -Future _notifyAboutNewMessages() async { + Future _notifyAboutNewMessages() async { final known = _knownIds; _knownIds = _messages.map((message) => message.id).toSet(); if (known == null) return; diff --git a/lib/ui/cards/connections_status_card.dart b/lib/ui/cards/connections_status_card.dart index 6383dc34..6bc39d72 100644 --- a/lib/ui/cards/connections_status_card.dart +++ b/lib/ui/cards/connections_status_card.dart @@ -74,10 +74,7 @@ class _ConnectionsStatusCardState extends State { 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, - ), + child: Icon(_expanded ? Icons.keyboard_arrow_up : Icons.chevron_right, color: colors.grey600), ), ], ), @@ -105,7 +102,9 @@ class _ConnectionsStatusCardState extends State { 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))), + 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), diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index 3f7f08a9..0fd7d68d 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -184,7 +184,9 @@ class HomePage extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: Text(label, style: fs14fw600.copyWith(color: colors.grey600))), + Expanded( + child: Text(label, style: fs14fw600.copyWith(color: colors.grey600)), + ), _iconBadge(icon, iconColor), ], ), @@ -198,7 +200,11 @@ class HomePage extends StatelessWidget { onTap: onLink, child: Text( linkLabel, - style: fs14fw600.copyWith(color: colors.primary, decoration: TextDecoration.underline, decorationColor: colors.primary), + style: fs14fw600.copyWith( + color: colors.primary, + decoration: TextDecoration.underline, + decorationColor: colors.primary, + ), ), ), ], @@ -226,7 +232,12 @@ class HomePage extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: Text(locale.translate(message.title ?? ''), style: fs18fw700.copyWith(color: colors.grey900))), + Expanded( + child: Text( + locale.translate(message.title ?? ''), + style: fs18fw700.copyWith(color: colors.grey900), + ), + ), _iconBadge(message.type.icon, colors.primary!), ], ), @@ -236,7 +247,12 @@ class HomePage extends StatelessWidget { ], if (body.isNotEmpty) ...[ const SizedBox(height: 8), - Text(locale.translate(body), maxLines: 3, overflow: TextOverflow.ellipsis, style: fs16fw400.copyWith(color: colors.grey900)), + Text( + locale.translate(body), + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: fs16fw400.copyWith(color: colors.grey900), + ), ], const SizedBox(height: 12), Row( @@ -282,10 +298,7 @@ class AppUpdateCard extends StatelessWidget { child: Row( children: [ Expanded( - child: Text( - locale.translate('pages.about.app_update'), - style: fs14fw600.copyWith(color: colors.grey900), - ), + child: Text(locale.translate('pages.about.app_update'), style: fs14fw600.copyWith(color: colors.grey900)), ), const SizedBox(width: 8), FilledButton( @@ -386,10 +399,7 @@ class StudyAboutCard extends StatelessWidget { 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), - ), + decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(100)), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/ui/pages/profile_page.dart b/lib/ui/pages/profile_page.dart index 7ded62d0..0c1a42d4 100644 --- a/lib/ui/pages/profile_page.dart +++ b/lib/ui/pages/profile_page.dart @@ -53,7 +53,12 @@ class ProfilePageState extends State { children: [ _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.account_id'), + widget.model.userId, + copyable: true, + ), _fieldTile( colors, locale.translate('pages.profile.full_name'), @@ -64,12 +69,19 @@ class ProfilePageState extends State { _fieldTile( colors, locale.translate('pages.profile.email'), - widget.model.isAnonymous ? locale.translate('pages.about.anonymous.anonymous') : widget.model.email, + widget.model.isAnonymous + ? locale.translate('pages.about.anonymous.anonymous') + : widget.model.email, ), ]), _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_id'), + widget.model.studyId, + copyable: true, + ), _fieldTile( colors, locale.translate('pages.profile.study_deployment_id'), @@ -88,7 +100,11 @@ class ProfilePageState extends State { widget.model.participantId, copyable: true, ), - _fieldTile(colors, locale.translate('pages.profile.participant_role'), widget.model.participantRole), + _fieldTile( + colors, + locale.translate('pages.profile.participant_role'), + widget.model.participantRole, + ), _fieldTile(colors, locale.translate('pages.profile.device_role'), widget.model.deviceRole), ]), _sectionHeader(colors, 'App'), @@ -250,7 +266,9 @@ class ProfilePageState extends State { children: [ Icon(icon, size: 22, color: iconColor), const SizedBox(width: 12), - Expanded(child: Text(title, style: fs16fw600.copyWith(color: colors.grey900))), + Expanded( + child: Text(title, style: fs16fw600.copyWith(color: colors.grey900)), + ), if (hasChevron) Icon(Icons.arrow_forward_ios, size: 16, color: colors.grey400), ], ), @@ -271,7 +289,7 @@ 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 { } + Future _downloadInformedConsent() async {} /// Sends and email to the researcher with the name of the study + user id void _sendEmailToContactResearcher(String email, String subject) async { diff --git a/lib/ui/pages/study_details_page.dart b/lib/ui/pages/study_details_page.dart index 9271f366..4631c625 100644 --- a/lib/ui/pages/study_details_page.dart +++ b/lib/ui/pages/study_details_page.dart @@ -54,10 +54,7 @@ class StudyDetailsPage extends StatelessWidget { 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), - ), + Text(locale.translate(model.description), style: fs14fw600.copyWith(color: colors.grey900)), const SizedBox(height: 12), InkWell( onTap: () async { @@ -94,7 +91,11 @@ class StudyDetailsPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _field(colors, locale.translate('widgets.study_card.responsible'), locale.translate(model.responsibleName)), + _field( + colors, + locale.translate('widgets.study_card.responsible'), + locale.translate(model.responsibleName), + ), Divider(height: 1, indent: 16, endIndent: 16, color: colors.grey200), _field( colors, @@ -102,13 +103,21 @@ class StudyDetailsPage extends StatelessWidget { 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)), + _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)), + child: _field( + colors, + locale.translate('widgets.study_card.study_purpose'), + locale.translate(model.purpose), + ), ), ], ), diff --git a/lib/view_models/home_view_model.dart b/lib/view_models/home_view_model.dart index f6daa8be..9a068f88 100644 --- a/lib/view_models/home_view_model.dart +++ b/lib/view_models/home_view_model.dart @@ -50,8 +50,7 @@ class HomePageViewModel extends ViewModel { 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 + Set get _activeDays => AppTaskController().userTaskQueue .where((task) => task.state == UserTaskState.done && task.doneTime != null) .map((task) => _dayKey(task.doneTime!.toLocal())) .toSet(); @@ -76,8 +75,9 @@ class HomePageViewModel extends ViewModel { 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; + 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. diff --git a/test/services_test.dart b/test/services_test.dart index fdac55c5..0ec59f9d 100644 --- a/test/services_test.dart +++ b/test/services_test.dart @@ -230,8 +230,10 @@ void main() { }); test('getInvitations sorts by study name, then deployment id', () async { - ActiveParticipationInvitation invitation(String name, String deploymentId) => - ActiveParticipationInvitation(Participation(deploymentId, 'participant-1', AssignedTo()), StudyInvitation(name)); + 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. diff --git a/test/view_models_test.dart b/test/view_models_test.dart index e70465b1..5e2b9025 100644 --- a/test/view_models_test.dart +++ b/test/view_models_test.dart @@ -179,8 +179,10 @@ void main() { expect(model.invitations, isEmpty); }); - ActiveParticipationInvitation invitation(String deploymentId) => - ActiveParticipationInvitation(Participation(deploymentId, 'participant-1', 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('dep-1')]); From 6db992123d889a5c5744373b435de95975350bd6 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 28 Jul 2026 11:53:38 +0200 Subject: [PATCH 20/24] refactor(statistics): rename the data visualization page and view model Names the page and its view model after the tab the user actually sees - StatisticsPage and StatisticsViewModel - matching the naming used everywhere else. The /data route is unchanged. --- lib/carp_study_app.dart | 4 +- lib/main.dart | 6 +- lib/ui/pages/carp_app_shell.dart | 4 +- lib/ui/pages/data_visualization_page.dart | 102 ------------------ lib/ui/pages/statistics_page.dart | 65 +++++++++++ ..._model.dart => statistics_view_model.dart} | 11 +- 6 files changed, 79 insertions(+), 113 deletions(-) delete mode 100644 lib/ui/pages/data_visualization_page.dart create mode 100644 lib/ui/pages/statistics_page.dart rename lib/view_models/{data_visualization_view_model.dart => statistics_view_model.dart} (93%) diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index 3d791174..5420e3e0 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -85,11 +85,11 @@ class CarpAppState extends State { // 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( key: state.pageKey, - child: DataVisualizationPage(bloc.appViewModel.dataVisualizationPageViewModel), + child: StatisticsPage(bloc.appViewModel.statisticsViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), ), diff --git a/lib/main.dart b/lib/main.dart index 5f0400e8..0cadd30a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -88,7 +88,7 @@ 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/data_visualization_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_view_model.dart'; @@ -99,6 +99,7 @@ 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'; @@ -109,7 +110,7 @@ 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'; @@ -157,6 +158,7 @@ part 'ui/cards/mobility_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/ui/pages/carp_app_shell.dart b/lib/ui/pages/carp_app_shell.dart index a8db6692..0fb72471 100644 --- a/lib/ui/pages/carp_app_shell.dart +++ b/lib/ui/pages/carp_app_shell.dart @@ -126,7 +126,7 @@ class CarpAppShellState extends State { if (location.startsWith(DeviceListPage.route)) { return 2; } - if (location.startsWith(DataVisualizationPage.route)) { + if (location.startsWith(StatisticsPage.route)) { return 3; } return -1; @@ -144,7 +144,7 @@ class CarpAppShellState extends State { context.go(DeviceListPage.route); break; case 3: - context.go(DataVisualizationPage.route); + context.go(StatisticsPage.route); break; case -1: context.go(CarpAppState.homeRoute); diff --git a/lib/ui/pages/data_visualization_page.dart b/lib/ui/pages/data_visualization_page.dart deleted file mode 100644 index d4c56931..00000000 --- a/lib/ui/pages/data_visualization_page.dart +++ /dev/null @@ -1,102 +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), - ), - CarpPageTitle(locale.translate('pages.data_viz.title')), - 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/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/view_models/data_visualization_view_model.dart b/lib/view_models/statistics_view_model.dart similarity index 93% rename from lib/view_models/data_visualization_view_model.dart rename to lib/view_models/statistics_view_model.dart index 3098b795..f17c0df7 100644 --- a/lib/view_models/data_visualization_view_model.dart +++ b/lib/view_models/statistics_view_model.dart @@ -1,13 +1,14 @@ part of carp_study_app; -/// View model for [DataVisualizationPage]. +/// View model for [StatisticsPage]. /// /// State: which measures the deployment collects, so only the cards backed by -/// real data are shown. +/// real data are shown, plus one view model per card. /// -/// Computed once on [init] from the deployment - the cards own their own data. -class DataVisualizationPageViewModel extends ViewModel { - DataVisualizationPageViewModel({StudyService? studyService}) : _studyService = studyService; +/// 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; From 77ef2d97cd374ee6bfb808711faeafe385e93b04 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 28 Jul 2026 11:53:48 +0200 Subject: [PATCH 21/24] feat(statistics): rebuild the statistics charts and their selection Adds a 14-day task completion chart and turns the task and survey rings into donuts, each section gated on data the study actually has: app tasks, two or more survey types, or the matching measure in the protocol. Drops the media and mobility cards. Every chart now selects the same way - the selected bar or slice stays solid, the rest fade, and the figures above the chart follow it. Several had a BarTouchData that was disabled, so their touch callbacks never ran, and an inverted alpha that lightened the selected bar instead. Also fixes the survey donut's centre, which counted survey types rather than completed surveys. --- assets/lang/da.json | 9 +- assets/lang/en.json | 9 +- assets/lang/es.json | 12 +- lib/ui/cards/activity_card.dart | 266 ++++++----- lib/ui/cards/heart_rate_card.dart | 416 +++++++++--------- lib/ui/cards/steps_card.dart | 124 +++--- lib/ui/cards/study_progress_card.dart | 237 +++++----- lib/ui/cards/survey_card.dart | 168 ++++--- lib/ui/cards/task_completion_card.dart | 150 +++++++ lib/ui/pages/home_page.dart | 19 +- lib/ui/widgets/carp_app_bar.dart | 17 + .../cards/study_progress_data_model.dart | 31 +- lib/view_models/home_view_model.dart | 3 - 13 files changed, 847 insertions(+), 614 deletions(-) create mode 100644 lib/ui/cards/task_completion_card.dart diff --git a/assets/lang/da.json b/assets/lang/da.json index 78518e17..c453561f 100644 --- a/assets/lang/da.json +++ b/assets/lang/da.json @@ -48,11 +48,12 @@ "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.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 +71,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.", diff --git a/assets/lang/en.json b/assets/lang/en.json index 3262e405..1f617d01 100644 --- a/assets/lang/en.json +++ b/assets/lang/en.json @@ -52,11 +52,12 @@ "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": "Task completed in last 14 days", "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 +75,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.", diff --git a/assets/lang/es.json b/assets/lang/es.json index 3cf23166..3c6ce453 100644 --- a/assets/lang/es.json +++ b/assets/lang/es.json @@ -29,11 +29,12 @@ "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.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 +48,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.", 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/heart_rate_card.dart b/lib/ui/cards/heart_rate_card.dart index 0830352c..143e4ffd 100644 --- a/lib/ui/cards/heart_rate_card.dart +++ b/lib/ui/cards/heart_rate_card.dart @@ -1,273 +1,287 @@ 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/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..f650cde5 100644 --- a/lib/ui/cards/study_progress_card.dart +++ b/lib/ui/cards/study_progress_card.dart @@ -1,166 +1,141 @@ 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 a87b5fc6..82aa7a0c 100644 --- a/lib/ui/cards/survey_card.dart +++ b/lib/ui/cards/survey_card.dart @@ -1,5 +1,10 @@ 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; @@ -15,89 +20,116 @@ class SurveyCard extends StatefulWidget { } 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: [ - if (widget.showTitle) - Padding( - padding: const EdgeInsets.only(left: 10.0), - child: Text(locale.translate('cards.survey.title').toUpperCase(), style: fs16fw400ls1), + // Tapping anywhere else on the card drops the selection. + child: GestureDetector( + onTap: () => setState(() => _selected = null), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.showTitle) + 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, + child: Row( + children: [ + Expanded(flex: 2, child: _legend(locale)), + Expanded(flex: 3, child: _donut()), + ], + ), ), - SizedBox( - height: 160, - width: MediaQuery.of(context).size.width * 0.9, - 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(), - ), + ], + ), + ), + ), + ); + } + + Widget _legend(RPLocalizations locale) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final (index, survey) in _surveys.indexed) + GestureDetector( + onTap: () => setState(() => _selected = _selected == index ? null : index), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: _shade(widget.colors[index], index), shape: BoxShape.circle), ), - ), - // 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: 8), + Text( + '${survey.value} ${locale.translate(survey.key).truncateTo(12)}', + style: _selected == index ? fs12fw700 : fs12fw400, ), - ), - ], + ], + ), ), ), - ], - ), + ], ), ); } - 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( + 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, + ), + ], + ), + ), + Text('$_centreCount', style: fs24fw700.copyWith(color: Theme.of(context).extension()!.grey800)), + ], + ); } } diff --git a/lib/ui/cards/task_completion_card.dart b/lib/ui/cards/task_completion_card.dart new file mode 100644 index 00000000..f839735a --- /dev/null +++ b/lib/ui/cards/task_completion_card.dart @@ -0,0 +1,150 @@ +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 the last one - today - until they do. + int _selected = StudyProgressCardViewModel.completionHistoryDays - 1; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension()!; + final locale = RPLocalizations.of(context)!; + final completions = widget.model.recentCompletions; + + return StudiesMaterial( + backgroundColor: colors.white!, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(locale.translate('cards.task_completion.title'), 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), + _selectedDay(colors, locale, completions), + const SizedBox(height: 8), + SizedBox(height: 140, child: _chart(context, completions)), + ], + ), + ), + ); + } + + /// What the selected bar stands for, so the tap has somewhere to report to. + Widget _selectedDay(CarpColors colors, RPLocalizations locale, List completions) { + return Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text('${completions[_selected]}', style: fs28fw700.copyWith(color: colors.grey900)), + const SizedBox(width: 6), + Text( + locale.translate('cards.study_progress.completed').toLowerCase(), + style: fs14fw600.copyWith(color: colors.grey600), + ), + const SizedBox(width: 8), + Text('·', style: fs14fw600.copyWith(color: colors.grey500)), + const SizedBox(width: 8), + Text(DateFormat('dd/MM').format(_dateOf(_selected)), 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) { + final index = response?.spot?.touchedBarGroupIndex; + if (index != null && index != _selected) setState(() => _selected = 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(), + // Solid for the selected day, washed out for the rest, so the + // selection reads at a glance. + color: 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/home_page.dart b/lib/ui/pages/home_page.dart index 0fd7d68d..569d1a41 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -26,9 +26,9 @@ class HomePage extends StatelessWidget { else ...[ AppUpdateCard(model: model), StudyAboutCard(model: model), - _sectionHeader(colors, 'Connections'), + CarpSectionTitle('Connections'), ConnectionsStatusCard(model: model), - _sectionHeader(colors, 'Your progress'), + CarpSectionTitle('Your progress'), IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -38,12 +38,8 @@ class HomePage extends StatelessWidget { ], ), ), - if (model.surveys.tasksTable.isNotEmpty) ...[ - _sectionHeader(colors, 'Completed Surveys'), - SurveyCard(model.surveys, showTitle: false), - ], if (model.messages.isNotEmpty) ...[ - _sectionHeader(colors, 'Feeds'), + CarpSectionTitle('Feeds'), for (final message in model.messages) _feedCard(context, colors, message), ], ], @@ -95,13 +91,6 @@ class HomePage extends StatelessWidget { ); } - 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)), - ); - } - /// "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) { @@ -127,7 +116,7 @@ class HomePage extends StatelessWidget { ], ), linkLabel: 'View Statistics', - onLink: () => context.go(DataVisualizationPage.route), + onLink: () => context.go(StatisticsPage.route), margin: const EdgeInsets.only(left: 16, right: 6, bottom: 16), ); } diff --git a/lib/ui/widgets/carp_app_bar.dart b/lib/ui/widgets/carp_app_bar.dart index 34f09132..45a2673f 100644 --- a/lib/ui/widgets/carp_app_bar.dart +++ b/lib/ui/widgets/carp_app_bar.dart @@ -18,6 +18,23 @@ class CarpPageTitle extends StatelessWidget { } } +/// 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}); 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/home_view_model.dart b/lib/view_models/home_view_model.dart index 9a068f88..9180cbbb 100644 --- a/lib/view_models/home_view_model.dart +++ b/lib/view_models/home_view_model.dart @@ -24,9 +24,6 @@ class HomePageViewModel extends ViewModel { StudyService get _study => _studyService ?? bloc.study; MessageService get _messages => _messageService ?? bloc.messages; - /// Per-survey completion counts for the "Completed Surveys" card. - final TaskCardViewModel surveys = TaskCardViewModel(AppTask.SURVEY_TYPE); - bool _healthConnectPromptPending = false; bool _appUpdateAvailable = false; List _connectionSources = const []; From 930619a28973dde69f83857a01fc9ad804f89709 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 28 Jul 2026 11:53:58 +0200 Subject: [PATCH 22/24] feat(statistics): back the demo charts with real CAMS measurements The placeholder data was pre-aggregated maps, so it proved nothing about the live path. It is now StepCount, PolarHR and Activity measurements stamped with the time they were taken, folded by the same code that folds readings off the sampling streams - connecting the real streams changes where the data comes from and nothing about how it is read. That aggregation now buckets by each measurement's own timestamp instead of DateTime.now(), which is also what makes replaying history possible. Demo data is off on a production deployment, derived from the deployment mode rather than a flag someone can leave switched on. --- lib/helpers/app_config.dart | 9 + lib/helpers/util.dart | 8 + lib/main.g.dart | 154 +++-- .../cards/activity_data_model.dart | 55 +- lib/view_models/cards/demo_chart_data.dart | 88 +++ .../cards/heart_rate_data_model.dart | 142 +++-- lib/view_models/cards/steps_data_model.dart | 39 +- lib/view_models/view_model.dart | 10 +- test/heart_rate_data_model_test.dart | 37 +- test/heart_rate_data_model_test.mocks.dart | 85 ++- test/services_test.mocks.dart | 526 +++++++++++++----- test/view_models_test.dart | 77 ++- 12 files changed, 934 insertions(+), 296 deletions(-) create mode 100644 lib/view_models/cards/demo_chart_data.dart diff --git a/lib/helpers/app_config.dart b/lib/helpers/app_config.dart index ab5bfd94..042a4621 100644 --- a/lib/helpers/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/util.dart b/lib/helpers/util.dart index 1713255c..426c6f21 100644 --- a/lib/helpers/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.g.dart b/lib/main.g.dart index 95750247..419a12ed 100644 --- a/lib/main.g.dart +++ b/lib/main.g.dart @@ -12,32 +12,40 @@ Participant _$ParticipantFromJson(Map json) => Participant( deviceRoleName: json['deviceRoleName'] as String?, participantId: json['participantId'] as String?, participantRoleName: json['participantRoleName'] as String?, - hasInformedConsentBeenAccepted: json['hasInformedConsentBeenAccepted'] as bool? ?? false, + hasInformedConsentBeenAccepted: + json['hasInformedConsentBeenAccepted'] as bool? ?? false, ); -Map _$ParticipantToJson(Participant instance) => { - 'studyId': ?instance.studyId, - 'studyDeploymentId': ?instance.studyDeploymentId, - 'deviceRoleName': ?instance.deviceRoleName, - 'participantId': ?instance.participantId, - 'participantRoleName': ?instance.participantRoleName, - 'hasInformedConsentBeenAccepted': instance.hasInformedConsentBeenAccepted, -}; +Map _$ParticipantToJson(Participant instance) => + { + 'studyId': ?instance.studyId, + 'studyDeploymentId': ?instance.studyDeploymentId, + 'deviceRoleName': ?instance.deviceRoleName, + 'participantId': ?instance.participantId, + 'participantRoleName': ?instance.participantRoleName, + 'hasInformedConsentBeenAccepted': instance.hasInformedConsentBeenAccepted, + }; WeeklyActivities _$WeeklyActivitiesFromJson(Map json) => WeeklyActivities() ..activities = (json['activities'] as Map).map( (k, e) => MapEntry( $enumDecode(_$ActivityTypeEnumMap, k), - (e as Map).map((k, e) => MapEntry(int.parse(k), (e as num).toInt())), + (e as Map).map( + (k, e) => MapEntry(int.parse(k), (e as num).toInt()), + ), ), ); -Map _$WeeklyActivitiesToJson(WeeklyActivities instance) => { - 'activities': instance.activities.map( - (k, e) => MapEntry(_$ActivityTypeEnumMap[k]!, e.map((k, e) => MapEntry(k.toString(), e))), - ), -}; +Map _$WeeklyActivitiesToJson(WeeklyActivities instance) => + { + 'activities': instance.activities.map( + (k, e) => MapEntry( + _$ActivityTypeEnumMap[k]!, + e.map((k, e) => MapEntry(k.toString(), e)), + ), + ), + }; const _$ActivityTypeEnumMap = { ActivityType.IN_VEHICLE: 'IN_VEHICLE', @@ -48,57 +56,87 @@ const _$ActivityTypeEnumMap = { ActivityType.UNKNOWN: 'UNKNOWN', }; -WeeklyMobility _$WeeklyMobilityFromJson(Map json) => WeeklyMobility() - ..weekMobility = (json['weekMobility'] as Map).map( - (k, e) => MapEntry(int.parse(k), DailyMobility.fromJson(e as Map)), - ); - -Map _$WeeklyMobilityToJson(WeeklyMobility instance) => { - 'weekMobility': instance.weekMobility.map((k, e) => MapEntry(k.toString(), e)), -}; - -DailyMobility _$DailyMobilityFromJson(Map json) => DailyMobility( - (json['weekday'] as num).toInt(), - (json['places'] as num).toInt(), - (json['homeStay'] as num).toInt(), - (json['distance'] as num).toDouble(), -); +WeeklyMobility _$WeeklyMobilityFromJson(Map json) => + WeeklyMobility() + ..weekMobility = (json['weekMobility'] as Map).map( + (k, e) => MapEntry( + int.parse(k), + DailyMobility.fromJson(e as Map), + ), + ); -Map _$DailyMobilityToJson(DailyMobility instance) => { - 'weekday': instance.weekday, - 'places': instance.places, - 'homeStay': instance.homeStay, - 'distance': instance.distance, -}; +Map _$WeeklyMobilityToJson(WeeklyMobility instance) => + { + 'weekMobility': instance.weekMobility.map( + (k, e) => MapEntry(k.toString(), e), + ), + }; + +DailyMobility _$DailyMobilityFromJson(Map json) => + DailyMobility( + (json['weekday'] as num).toInt(), + (json['places'] as num).toInt(), + (json['homeStay'] as num).toInt(), + (json['distance'] as num).toDouble(), + ); + +Map _$DailyMobilityToJson(DailyMobility instance) => + { + 'weekday': instance.weekday, + 'places': instance.places, + 'homeStay': instance.homeStay, + 'distance': instance.distance, + }; WeeklySteps _$WeeklyStepsFromJson(Map json) => WeeklySteps() ..weeklySteps = (json['weeklySteps'] as Map).map( (k, e) => MapEntry(int.parse(k), (e as num).toInt()), ); -Map _$WeeklyStepsToJson(WeeklySteps instance) => { +Map _$WeeklyStepsToJson( + WeeklySteps instance, +) => { 'weeklySteps': instance.weeklySteps.map((k, e) => MapEntry(k.toString(), e)), }; -HourlyHeartRate _$HourlyHeartRateFromJson(Map json) => HourlyHeartRate() - ..hourlyHeartRate = (json['hourlyHeartRate'] 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)), - 'lastUpdated': instance.lastUpdated.toIso8601String(), - 'maxHeartRate': ?instance.maxHeartRate, - 'minHeartRate': ?instance.minHeartRate, -}; - -HeartRateMinMaxPrHour _$HeartRateMinMaxPrHourFromJson(Map json) => - HeartRateMinMaxPrHour((json['min'] as num?)?.toDouble(), (json['max'] as num?)?.toDouble()); +HourlyHeartRate _$HourlyHeartRateFromJson(Map json) => + HourlyHeartRate() + ..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, + }; + +HeartRateMinMaxPrHour _$HeartRateMinMaxPrHourFromJson( + Map json, +) => HeartRateMinMaxPrHour( + (json['min'] as num?)?.toDouble(), + (json['max'] as num?)?.toDouble(), +); -Map _$HeartRateMinMaxPrHourToJson(HeartRateMinMaxPrHour instance) => { - 'min': ?instance.min, - 'max': ?instance.max, -}; +Map _$HeartRateMinMaxPrHourToJson( + HeartRateMinMaxPrHour instance, +) => {'min': ?instance.min, 'max': ?instance.max}; diff --git a/lib/view_models/cards/activity_data_model.dart b/lib/view_models/cards/activity_data_model.dart index cd6b3d94..048010ea 100644 --- a/lib/view_models/cards/activity_data_model.dart +++ b/lib/view_models/cards/activity_data_model.dart @@ -5,8 +5,43 @@ 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,21 +68,7 @@ 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..55224a6e --- /dev/null +++ b/lib/view_models/cards/demo_chart_data.dart @@ -0,0 +1,88 @@ +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 26ff32dd..2a6602b5 100644 --- a/lib/view_models/cards/heart_rate_data_model.dart +++ b/lib/view_models/cards/heart_rate_data_model.dart @@ -6,26 +6,84 @@ 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; + } - HeartRateMinMaxPrHour get dayMinMax => HeartRateMinMaxPrHour(model.minHeartRate, model.maxHeartRate); + /// 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; - final StreamGroup _group = StreamGroup.broadcast(); + 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(); - 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,14 +92,10 @@ 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 +111,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 +145,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 +153,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/steps_data_model.dart b/lib/view_models/cards/steps_data_model.dart index d4fbb3d4..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,13 +62,8 @@ 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/view_model.dart b/lib/view_models/view_model.dart index 1a0adfb0..bcb72a9f 100644 --- a/lib/view_models/view_model.dart +++ b/lib/view_models/view_model.dart @@ -200,7 +200,7 @@ class HourlyMeasure { 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(); @@ -213,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; @@ -228,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); @@ -242,7 +242,7 @@ class AppViewModel extends ViewModel { _homePageViewModel.clear(); _taskListPageViewModel.clear(); _studyPageViewModel.clear(); - _dataVisualizationPageViewModel.clear(); + _statisticsViewModel.clear(); _devicesPageViewModel.clear(); _profilePageViewModel.clear(); @@ -256,7 +256,7 @@ class AppViewModel extends ViewModel { _homePageViewModel.dispose(); _taskListPageViewModel.dispose(); _studyPageViewModel.dispose(); - _dataVisualizationPageViewModel.dispose(); + _statisticsViewModel.dispose(); _devicesPageViewModel.dispose(); _profilePageViewModel.dispose(); 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/heart_rate_data_model_test.mocks.dart b/test/heart_rate_data_model_test.mocks.dart index 2c04d8ba..99bc2a97 100644 --- a/test/heart_rate_data_model_test.mocks.dart +++ b/test/heart_rate_data_model_test.mocks.dart @@ -26,33 +26,48 @@ import 'package:permission_handler/permission_handler.dart' as _i4; // ignore_for_file: subtype_of_sealed_class // ignore_for_file: invalid_use_of_internal_member -class _FakeSmartphoneStudy_0 extends _i1.SmartFake implements _i2.SmartphoneStudy { - _FakeSmartphoneStudy_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +class _FakeSmartphoneStudy_0 extends _i1.SmartFake + implements _i2.SmartphoneStudy { + _FakeSmartphoneStudy_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } -class _FakeSmartphoneDeploymentExecutor_1 extends _i1.SmartFake implements _i2.SmartphoneDeploymentExecutor { - _FakeSmartphoneDeploymentExecutor_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +class _FakeSmartphoneDeploymentExecutor_1 extends _i1.SmartFake + implements _i2.SmartphoneDeploymentExecutor { + _FakeSmartphoneDeploymentExecutor_1( + Object parent, + Invocation parentInvocation, + ) : super(parent, parentInvocation); } /// A class which mocks [SmartphoneStudyController]. /// /// See the documentation for Mockito's code generation for more information. -class MockSmartphoneStudyController extends _i1.Mock implements _i2.SmartphoneStudyController { +class MockSmartphoneStudyController extends _i1.Mock + implements _i2.SmartphoneStudyController { @override _i2.SmartphoneStudy get study => (super.noSuchMethod( Invocation.getter(#study), - returnValue: _FakeSmartphoneStudy_0(this, Invocation.getter(#study)), - returnValueForMissingStub: _FakeSmartphoneStudy_0(this, Invocation.getter(#study)), + returnValue: _FakeSmartphoneStudy_0( + this, + Invocation.getter(#study), + ), + returnValueForMissingStub: _FakeSmartphoneStudy_0( + this, + Invocation.getter(#study), + ), ) as _i2.SmartphoneStudy); @override - List<_i3.DeviceConfiguration<_i3.DeviceRegistration>> get remainingDevicesToRegister => + List<_i3.DeviceConfiguration<_i3.DeviceRegistration>> + get remainingDevicesToRegister => (super.noSuchMethod( Invocation.getter(#remainingDevicesToRegister), returnValue: <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], - returnValueForMissingStub: <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], + returnValueForMissingStub: + <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], ) as List<_i3.DeviceConfiguration<_i3.DeviceRegistration>>); @@ -69,8 +84,14 @@ class MockSmartphoneStudyController extends _i1.Mock implements _i2.SmartphoneSt _i2.SmartphoneDeploymentExecutor get executor => (super.noSuchMethod( Invocation.getter(#executor), - returnValue: _FakeSmartphoneDeploymentExecutor_1(this, Invocation.getter(#executor)), - returnValueForMissingStub: _FakeSmartphoneDeploymentExecutor_1(this, Invocation.getter(#executor)), + returnValue: _FakeSmartphoneDeploymentExecutor_1( + this, + Invocation.getter(#executor), + ), + returnValueForMissingStub: _FakeSmartphoneDeploymentExecutor_1( + this, + Invocation.getter(#executor), + ), ) as _i2.SmartphoneDeploymentExecutor); @@ -78,8 +99,14 @@ class MockSmartphoneStudyController extends _i1.Mock implements _i2.SmartphoneSt String get privacySchemaName => (super.noSuchMethod( Invocation.getter(#privacySchemaName), - returnValue: _i5.dummyValue(this, Invocation.getter(#privacySchemaName)), - returnValueForMissingStub: _i5.dummyValue(this, Invocation.getter(#privacySchemaName)), + returnValue: _i5.dummyValue( + this, + Invocation.getter(#privacySchemaName), + ), + returnValueForMissingStub: _i5.dummyValue( + this, + Invocation.getter(#privacySchemaName), + ), ) as String); @@ -102,7 +129,9 @@ class MockSmartphoneStudyController extends _i1.Mock implements _i2.SmartphoneSt as _i6.Stream<_i3.Measurement>); @override - _i6.Future tryRegisterConnectedDevice(_i3.DeviceConfiguration<_i3.DeviceRegistration>? device) => + _i6.Future tryRegisterConnectedDevice( + _i3.DeviceConfiguration<_i3.DeviceRegistration>? device, + ) => (super.noSuchMethod( Invocation.method(#tryRegisterConnectedDevice, [device]), returnValue: _i6.Future.value(), @@ -120,7 +149,9 @@ class MockSmartphoneStudyController extends _i1.Mock implements _i2.SmartphoneSt as _i6.Future); @override - _i6.Future tryUnregisterDisconnectedDevice(_i3.DeviceConfiguration<_i3.DeviceRegistration>? device) => + _i6.Future tryUnregisterDisconnectedDevice( + _i3.DeviceConfiguration<_i3.DeviceRegistration>? device, + ) => (super.noSuchMethod( Invocation.method(#tryUnregisterDisconnectedDevice, [device]), returnValue: _i6.Future.value(), @@ -129,7 +160,9 @@ class MockSmartphoneStudyController extends _i1.Mock implements _i2.SmartphoneSt as _i6.Future); @override - _i6.Future tryReregisterDevice(_i3.DeviceConfiguration<_i3.DeviceRegistration>? device) => + _i6.Future tryReregisterDevice( + _i3.DeviceConfiguration<_i3.DeviceRegistration>? device, + ) => (super.noSuchMethod( Invocation.method(#tryReregisterDevice, [device]), returnValue: _i6.Future.value(), @@ -147,14 +180,26 @@ class MockSmartphoneStudyController extends _i1.Mock implements _i2.SmartphoneSt as _i6.Future); @override - void restart() => super.noSuchMethod(Invocation.method(#restart, []), returnValueForMissingStub: null); + void restart() => super.noSuchMethod( + Invocation.method(#restart, []), + returnValueForMissingStub: null, + ); @override - void resume() => super.noSuchMethod(Invocation.method(#resume, []), returnValueForMissingStub: null); + void resume() => super.noSuchMethod( + Invocation.method(#resume, []), + returnValueForMissingStub: null, + ); @override - void pause() => super.noSuchMethod(Invocation.method(#pause, []), returnValueForMissingStub: null); + void pause() => super.noSuchMethod( + Invocation.method(#pause, []), + returnValueForMissingStub: null, + ); @override - void dispose() => super.noSuchMethod(Invocation.method(#dispose, []), returnValueForMissingStub: null); + void dispose() => super.noSuchMethod( + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); } diff --git a/test/services_test.mocks.dart b/test/services_test.mocks.dart index eca70298..a11a816b 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; @@ -30,48 +30,67 @@ import 'package:research_package/research_package.dart' as _i8; // ignore_for_file: invalid_use_of_internal_member class _FakeUri_0 extends _i1.SmartFake implements Uri { - _FakeUri_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); + _FakeUri_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeCarpApp_1 extends _i1.SmartFake implements _i2.CarpApp { - _FakeCarpApp_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); + _FakeCarpApp_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } -class _FakeCarpAuthProperties_2 extends _i1.SmartFake implements _i3.CarpAuthProperties { - _FakeCarpAuthProperties_2(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +class _FakeCarpAuthProperties_2 extends _i1.SmartFake + implements _i3.CarpAuthProperties { + _FakeCarpAuthProperties_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeCarpUser_3 extends _i1.SmartFake implements _i3.CarpUser { - _FakeCarpUser_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); + _FakeCarpUser_3(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeAppTask_4 extends _i1.SmartFake implements _i4.AppTask { - _FakeAppTask_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); + _FakeAppTask_4(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeDateTime_5 extends _i1.SmartFake implements DateTime { - _FakeDateTime_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); + _FakeDateTime_5(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeAppTaskExecutor_6 extends _i1.SmartFake implements _i4.AppTaskExecutor { - _FakeAppTaskExecutor_6(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); + _FakeAppTaskExecutor_6(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } -class _FakeBackgroundTaskExecutor_7 extends _i1.SmartFake implements _i4.BackgroundTaskExecutor { - _FakeBackgroundTaskExecutor_7(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +class _FakeBackgroundTaskExecutor_7 extends _i1.SmartFake + implements _i4.BackgroundTaskExecutor { + _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( Invocation.getter(#uri), returnValue: _FakeUri_0(this, Invocation.getter(#uri)), - returnValueForMissingStub: _FakeUri_0(this, Invocation.getter(#uri)), + returnValueForMissingStub: _FakeUri_0( + this, + Invocation.getter(#uri), + ), ) as Uri); @@ -80,7 +99,10 @@ class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { (super.noSuchMethod( Invocation.getter(#authUri), returnValue: _FakeUri_0(this, Invocation.getter(#authUri)), - returnValueForMissingStub: _FakeUri_0(this, Invocation.getter(#authUri)), + returnValueForMissingStub: _FakeUri_0( + this, + Invocation.getter(#authUri), + ), ) as Uri); @@ -89,7 +111,10 @@ class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { (super.noSuchMethod( Invocation.getter(#app), returnValue: _FakeCarpApp_1(this, Invocation.getter(#app)), - returnValueForMissingStub: _FakeCarpApp_1(this, Invocation.getter(#app)), + returnValueForMissingStub: _FakeCarpApp_1( + this, + Invocation.getter(#app), + ), ) as _i2.CarpApp); @@ -97,35 +122,53 @@ class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { _i3.CarpAuthProperties get authProperties => (super.noSuchMethod( Invocation.getter(#authProperties), - returnValue: _FakeCarpAuthProperties_2(this, Invocation.getter(#authProperties)), - returnValueForMissingStub: _FakeCarpAuthProperties_2(this, Invocation.getter(#authProperties)), + returnValue: _FakeCarpAuthProperties_2( + this, + Invocation.getter(#authProperties), + ), + returnValueForMissingStub: _FakeCarpAuthProperties_2( + this, + Invocation.getter(#authProperties), + ), ) as _i3.CarpAuthProperties); @override bool get isAuthenticated => - (super.noSuchMethod(Invocation.getter(#isAuthenticated), returnValue: false, returnValueForMissingStub: false) + (super.noSuchMethod( + Invocation.getter(#isAuthenticated), + returnValue: false, + returnValueForMissingStub: false, + ) 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); + set user(_i3.CarpUser? user) => super.noSuchMethod( + Invocation.setter(#user, user), + returnValueForMissingStub: null, + ); @override - set invitations(List<_i6.ActiveParticipationInvitation>? value) => - super.noSuchMethod(Invocation.setter(#invitations, value), returnValueForMissingStub: null); + set invitations(List<_i5.ActiveParticipationInvitation>? value) => + super.noSuchMethod( + Invocation.setter(#invitations, value), + returnValueForMissingStub: null, + ); @override - set study(_i4.SmartphoneStudy? study) => - super.noSuchMethod(Invocation.setter(#study, study), returnValueForMissingStub: null); + set study(_i4.SmartphoneStudy? study) => super.noSuchMethod( + Invocation.setter(#study, study), + returnValueForMissingStub: null, + ); @override _i7.Future initialize() => @@ -158,7 +201,9 @@ class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { _i7.Future<_i3.CarpUser> refresh() => (super.noSuchMethod( Invocation.method(#refresh, []), - returnValue: _i7.Future<_i3.CarpUser>.value(_FakeCarpUser_3(this, Invocation.method(#refresh, []))), + returnValue: _i7.Future<_i3.CarpUser>.value( + _FakeCarpUser_3(this, Invocation.method(#refresh, [])), + ), returnValueForMissingStub: _i7.Future<_i3.CarpUser>.value( _FakeCarpUser_3(this, Invocation.method(#refresh, [])), ), @@ -175,51 +220,81 @@ 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>[], - ), - returnValueForMissingStub: _i7.Future>.value( - <_i6.ActiveParticipationInvitation>[], - ), + returnValue: + _i7.Future>.value( + <_i5.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]), + 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) + (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( Invocation.getter(#username), - returnValue: _i9.dummyValue(this, Invocation.getter(#username)), - returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#username)), + returnValue: _i9.dummyValue( + this, + Invocation.getter(#username), + ), + returnValueForMissingStub: _i9.dummyValue( + this, + Invocation.getter(#username), + ), ) as String); @@ -227,8 +302,14 @@ class MockAuthService extends _i1.Mock implements _i5.AuthService { String get friendlyUsername => (super.noSuchMethod( Invocation.getter(#friendlyUsername), - returnValue: _i9.dummyValue(this, Invocation.getter(#friendlyUsername)), - returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#friendlyUsername)), + returnValue: _i9.dummyValue( + this, + Invocation.getter(#friendlyUsername), + ), + returnValueForMissingStub: _i9.dummyValue( + this, + Invocation.getter(#friendlyUsername), + ), ) as String); @@ -237,18 +318,21 @@ class MockAuthService extends _i1.Mock implements _i5.AuthService { (super.noSuchMethod( Invocation.getter(#serverUri), returnValue: _FakeUri_0(this, Invocation.getter(#serverUri)), - returnValueForMissingStub: _FakeUri_0(this, Invocation.getter(#serverUri)), + returnValueForMissingStub: _FakeUri_0( + this, + Invocation.getter(#serverUri), + ), ) 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 +344,19 @@ 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>[], - ), - returnValueForMissingStub: _i7.Future>.value( - <_i6.ActiveParticipationInvitation>[], - ), + returnValue: + _i7.Future>.value( + <_i5.ActiveParticipationInvitation>[], + ), + returnValueForMissingStub: + _i7.Future>.value( + <_i5.ActiveParticipationInvitation>[], + ), ) - as _i7.Future>); + as _i7.Future>); @override _i7.Future authenticate() => @@ -303,7 +389,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 +416,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]. @@ -341,7 +436,10 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { (super.noSuchMethod( Invocation.getter(#task), returnValue: _FakeAppTask_4(this, Invocation.getter(#task)), - returnValueForMissingStub: _FakeAppTask_4(this, Invocation.getter(#task)), + returnValueForMissingStub: _FakeAppTask_4( + this, + Invocation.getter(#task), + ), ) as _i4.AppTask); @@ -350,7 +448,10 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { (super.noSuchMethod( Invocation.getter(#id), returnValue: _i9.dummyValue(this, Invocation.getter(#id)), - returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#id)), + returnValueForMissingStub: _i9.dummyValue( + this, + Invocation.getter(#id), + ), ) as String); @@ -359,7 +460,10 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { (super.noSuchMethod( Invocation.getter(#type), returnValue: _i9.dummyValue(this, Invocation.getter(#type)), - returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#type)), + returnValueForMissingStub: _i9.dummyValue( + this, + Invocation.getter(#type), + ), ) as String); @@ -368,7 +472,10 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { (super.noSuchMethod( Invocation.getter(#name), returnValue: _i9.dummyValue(this, Invocation.getter(#name)), - returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#name)), + returnValueForMissingStub: _i9.dummyValue( + this, + Invocation.getter(#name), + ), ) as String); @@ -376,8 +483,14 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { String get title => (super.noSuchMethod( Invocation.getter(#title), - returnValue: _i9.dummyValue(this, Invocation.getter(#title)), - returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#title)), + returnValue: _i9.dummyValue( + this, + Invocation.getter(#title), + ), + returnValueForMissingStub: _i9.dummyValue( + this, + Invocation.getter(#title), + ), ) as String); @@ -385,8 +498,14 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { String get description => (super.noSuchMethod( Invocation.getter(#description), - returnValue: _i9.dummyValue(this, Invocation.getter(#description)), - returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#description)), + returnValue: _i9.dummyValue( + this, + Invocation.getter(#description), + ), + returnValueForMissingStub: _i9.dummyValue( + this, + Invocation.getter(#description), + ), ) as String); @@ -394,14 +513,24 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { String get instructions => (super.noSuchMethod( Invocation.getter(#instructions), - returnValue: _i9.dummyValue(this, Invocation.getter(#instructions)), - returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#instructions)), + returnValue: _i9.dummyValue( + this, + Invocation.getter(#instructions), + ), + returnValueForMissingStub: _i9.dummyValue( + this, + Invocation.getter(#instructions), + ), ) as String); @override bool get notification => - (super.noSuchMethod(Invocation.getter(#notification), returnValue: false, returnValueForMissingStub: false) + (super.noSuchMethod( + Invocation.getter(#notification), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); @override @@ -409,7 +538,10 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { (super.noSuchMethod( Invocation.getter(#triggerTime), returnValue: _FakeDateTime_5(this, Invocation.getter(#triggerTime)), - returnValueForMissingStub: _FakeDateTime_5(this, Invocation.getter(#triggerTime)), + returnValueForMissingStub: _FakeDateTime_5( + this, + Invocation.getter(#triggerTime), + ), ) as DateTime); @@ -418,7 +550,10 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { (super.noSuchMethod( Invocation.getter(#enqueued), returnValue: _FakeDateTime_5(this, Invocation.getter(#enqueued)), - returnValueForMissingStub: _FakeDateTime_5(this, Invocation.getter(#enqueued)), + returnValueForMissingStub: _FakeDateTime_5( + this, + Invocation.getter(#enqueued), + ), ) as DateTime); @@ -433,7 +568,11 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { @override bool get availableForUser => - (super.noSuchMethod(Invocation.getter(#availableForUser), returnValue: false, returnValueForMissingStub: false) + (super.noSuchMethod( + Invocation.getter(#availableForUser), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); @override @@ -458,8 +597,14 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { _i4.AppTaskExecutor<_i4.AppTask> get appTaskExecutor => (super.noSuchMethod( Invocation.getter(#appTaskExecutor), - returnValue: _FakeAppTaskExecutor_6<_i4.AppTask>(this, Invocation.getter(#appTaskExecutor)), - returnValueForMissingStub: _FakeAppTaskExecutor_6<_i4.AppTask>(this, Invocation.getter(#appTaskExecutor)), + returnValue: _FakeAppTaskExecutor_6<_i4.AppTask>( + this, + Invocation.getter(#appTaskExecutor), + ), + returnValueForMissingStub: _FakeAppTaskExecutor_6<_i4.AppTask>( + this, + Invocation.getter(#appTaskExecutor), + ), ) as _i4.AppTaskExecutor<_i4.AppTask>); @@ -467,112 +612,185 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { _i4.BackgroundTaskExecutor get backgroundTaskExecutor => (super.noSuchMethod( Invocation.getter(#backgroundTaskExecutor), - returnValue: _FakeBackgroundTaskExecutor_7(this, Invocation.getter(#backgroundTaskExecutor)), - returnValueForMissingStub: _FakeBackgroundTaskExecutor_7(this, Invocation.getter(#backgroundTaskExecutor)), + returnValue: _FakeBackgroundTaskExecutor_7( + this, + Invocation.getter(#backgroundTaskExecutor), + ), + returnValueForMissingStub: _FakeBackgroundTaskExecutor_7( + this, + Invocation.getter(#backgroundTaskExecutor), + ), ) as _i4.BackgroundTaskExecutor); @override bool get hasWidget => - (super.noSuchMethod(Invocation.getter(#hasWidget), returnValue: false, returnValueForMissingStub: false) as bool); + (super.noSuchMethod( + Invocation.getter(#hasWidget), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool); @override - set id(String? value) => super.noSuchMethod(Invocation.setter(#id, value), returnValueForMissingStub: null); + set id(String? value) => super.noSuchMethod( + Invocation.setter(#id, value), + returnValueForMissingStub: null, + ); @override - set triggerTime(DateTime? value) => - super.noSuchMethod(Invocation.setter(#triggerTime, value), returnValueForMissingStub: null); + set triggerTime(DateTime? value) => super.noSuchMethod( + Invocation.setter(#triggerTime, value), + returnValueForMissingStub: null, + ); @override - set enqueued(DateTime? value) => - super.noSuchMethod(Invocation.setter(#enqueued, value), returnValueForMissingStub: null); + set enqueued(DateTime? value) => super.noSuchMethod( + Invocation.setter(#enqueued, value), + returnValueForMissingStub: null, + ); @override - set doneTime(DateTime? value) => - super.noSuchMethod(Invocation.setter(#doneTime, value), returnValueForMissingStub: null); + set doneTime(DateTime? value) => super.noSuchMethod( + Invocation.setter(#doneTime, value), + returnValueForMissingStub: null, + ); @override - set state(_i4.UserTaskState? state) => - super.noSuchMethod(Invocation.setter(#state, state), returnValueForMissingStub: null); + set state(_i4.UserTaskState? state) => super.noSuchMethod( + Invocation.setter(#state, state), + returnValueForMissingStub: null, + ); @override - set hasNotificationBeenCreated(bool? value) => - super.noSuchMethod(Invocation.setter(#hasNotificationBeenCreated, value), returnValueForMissingStub: null); + set hasNotificationBeenCreated(bool? value) => super.noSuchMethod( + Invocation.setter(#hasNotificationBeenCreated, value), + returnValueForMissingStub: null, + ); @override set backgroundTaskExecutor(_i4.BackgroundTaskExecutor? value) => - super.noSuchMethod(Invocation.setter(#backgroundTaskExecutor, value), returnValueForMissingStub: null); + 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); + void onStart() => super.noSuchMethod( + Invocation.method(#onStart, []), + returnValueForMissingStub: null, + ); @override - void onCancel({bool? dequeue = false}) => - super.noSuchMethod(Invocation.method(#onCancel, [], {#dequeue: dequeue}), returnValueForMissingStub: null); + void onCancel({bool? dequeue = false}) => super.noSuchMethod( + Invocation.method(#onCancel, [], {#dequeue: dequeue}), + returnValueForMissingStub: null, + ); @override - void onExpired() => super.noSuchMethod(Invocation.method(#onExpired, []), returnValueForMissingStub: null); + 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, ); @override - void onNotification() => super.noSuchMethod(Invocation.method(#onNotification, []), returnValueForMissingStub: null); + void onNotification() => super.noSuchMethod( + Invocation.method(#onNotification, []), + returnValueForMissingStub: null, + ); } /// 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); + (super.noSuchMethod( + Invocation.getter(#hasStudy), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool); @override bool get isDeployed => - (super.noSuchMethod(Invocation.getter(#isDeployed), returnValue: false, returnValueForMissingStub: false) + (super.noSuchMethod( + Invocation.getter(#isDeployed), + returnValue: false, + returnValueForMissingStub: false, + ) 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); + (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); + 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 +802,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,36 +829,65 @@ class MockStudyService extends _i1.Mock implements _i5.StudyService { as _i7.Future); @override - void addMeasurement(_i6.Measurement? measurement) => - super.noSuchMethod(Invocation.method(#addMeasurement, [measurement]), returnValueForMissingStub: null); + void addMeasurement(_i5.Measurement? measurement) => super.noSuchMethod( + Invocation.method(#addMeasurement, [measurement]), + returnValueForMissingStub: null, + ); @override bool hasMeasures() => - (super.noSuchMethod(Invocation.method(#hasMeasures, []), returnValue: false, returnValueForMissingStub: false) + (super.noSuchMethod( + Invocation.method(#hasMeasures, []), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); @override bool hasMeasure(String? type) => - (super.noSuchMethod(Invocation.method(#hasMeasure, [type]), returnValue: false, returnValueForMissingStub: false) + (super.noSuchMethod( + Invocation.method(#hasMeasure, [type]), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); @override bool hasUserTasks() => - (super.noSuchMethod(Invocation.method(#hasUserTasks, []), returnValue: false, returnValueForMissingStub: false) + (super.noSuchMethod( + Invocation.method(#hasUserTasks, []), + returnValue: false, + returnValueForMissingStub: false, + ) 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) => - super.noSuchMethod(Invocation.method(#setParticipantData, [data]), returnValueForMissingStub: null); + void setParticipantData(Map? data) => super.noSuchMethod( + Invocation.method(#setParticipantData, [data]), + returnValueForMissingStub: null, + ); @override _i7.Future deployLocalProtocol() => diff --git a/test/view_models_test.dart b/test/view_models_test.dart index 5e2b9025..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'; @@ -293,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()); From 610149d45a0467148e2b84bdfc24680c3e300039 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 28 Jul 2026 14:06:18 +0200 Subject: [PATCH 23/24] fix: make consistent chart styling --- lib/ui/cards/survey_card.dart | 81 +++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 38 deletions(-) diff --git a/lib/ui/cards/survey_card.dart b/lib/ui/cards/survey_card.dart index 82aa7a0c..c52596b1 100644 --- a/lib/ui/cards/survey_card.dart +++ b/lib/ui/cards/survey_card.dart @@ -43,25 +43,23 @@ class _SurveyCardState extends State { // 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(8.0), + padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.showTitle) Padding( - padding: const EdgeInsets.only(left: 10.0), + padding: const EdgeInsets.only(bottom: 8), child: Text(locale.translate('cards.survey.title').toUpperCase(), style: fs16fw400ls1), ), - SizedBox( - height: 160, - width: MediaQuery.of(context).size.width * 0.9, - child: Row( - children: [ - Expanded(flex: 2, child: _legend(locale)), - Expanded(flex: 3, child: _donut()), - ], - ), + Row( + children: [ + Expanded(child: _legend(locale)), + SizedBox(width: 110, height: 110, child: _donut()), + ], ), ], ), @@ -71,35 +69,38 @@ class _SurveyCardState extends State { } Widget _legend(RPLocalizations locale) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (final (index, survey) in _surveys.indexed) - GestureDetector( - onTap: () => setState(() => _selected = _selected == index ? null : index), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration(color: _shade(widget.colors[index], index), shape: BoxShape.circle), - ), - const SizedBox(width: 8), - Text( - '${survey.value} ${locale.translate(survey.key).truncateTo(12)}', - style: _selected == index ? fs12fw700 : fs12fw400, + 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: [ + // 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)), + ), + 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), ), - ], - ), + ), + ], ), ), - ], - ), + ), + ], ); } @@ -109,6 +110,8 @@ class _SurveyCardState extends State { children: [ PieChart( PieChartData( + centerSpaceRadius: 28, + sectionsSpace: 2, startDegreeOffset: 270, pieTouchData: PieTouchData( touchCallback: (event, response) { @@ -124,11 +127,13 @@ class _SurveyCardState extends State { 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()!.grey800)), + Text('$_centreCount', style: fs24fw700.copyWith(color: Theme.of(context).extension()!.grey900)), ], ); } From 51b5fad40f10a575e258734991a476332bb94491 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 28 Jul 2026 14:06:36 +0200 Subject: [PATCH 24/24] fix: dart format --- lib/main.g.dart | 158 +++---- lib/ui/cards/heart_rate_card.dart | 27 +- lib/ui/cards/study_progress_card.dart | 4 +- lib/ui/cards/task_completion_card.dart | 4 +- .../cards/activity_data_model.dart | 3 +- lib/view_models/cards/demo_chart_data.dart | 44 +- .../cards/heart_rate_data_model.dart | 4 +- test/heart_rate_data_model_test.mocks.dart | 85 +--- test/services_test.mocks.dart | 420 +++++------------- 9 files changed, 242 insertions(+), 507 deletions(-) diff --git a/lib/main.g.dart b/lib/main.g.dart index 419a12ed..eae4abea 100644 --- a/lib/main.g.dart +++ b/lib/main.g.dart @@ -12,40 +12,32 @@ Participant _$ParticipantFromJson(Map json) => Participant( deviceRoleName: json['deviceRoleName'] as String?, participantId: json['participantId'] as String?, participantRoleName: json['participantRoleName'] as String?, - hasInformedConsentBeenAccepted: - json['hasInformedConsentBeenAccepted'] as bool? ?? false, + hasInformedConsentBeenAccepted: json['hasInformedConsentBeenAccepted'] as bool? ?? false, ); -Map _$ParticipantToJson(Participant instance) => - { - 'studyId': ?instance.studyId, - 'studyDeploymentId': ?instance.studyDeploymentId, - 'deviceRoleName': ?instance.deviceRoleName, - 'participantId': ?instance.participantId, - 'participantRoleName': ?instance.participantRoleName, - 'hasInformedConsentBeenAccepted': instance.hasInformedConsentBeenAccepted, - }; +Map _$ParticipantToJson(Participant instance) => { + 'studyId': ?instance.studyId, + 'studyDeploymentId': ?instance.studyDeploymentId, + 'deviceRoleName': ?instance.deviceRoleName, + 'participantId': ?instance.participantId, + 'participantRoleName': ?instance.participantRoleName, + 'hasInformedConsentBeenAccepted': instance.hasInformedConsentBeenAccepted, +}; WeeklyActivities _$WeeklyActivitiesFromJson(Map json) => WeeklyActivities() ..activities = (json['activities'] as Map).map( (k, e) => MapEntry( $enumDecode(_$ActivityTypeEnumMap, k), - (e as Map).map( - (k, e) => MapEntry(int.parse(k), (e as num).toInt()), - ), + (e as Map).map((k, e) => MapEntry(int.parse(k), (e as num).toInt())), ), ); -Map _$WeeklyActivitiesToJson(WeeklyActivities instance) => - { - 'activities': instance.activities.map( - (k, e) => MapEntry( - _$ActivityTypeEnumMap[k]!, - e.map((k, e) => MapEntry(k.toString(), e)), - ), - ), - }; +Map _$WeeklyActivitiesToJson(WeeklyActivities instance) => { + 'activities': instance.activities.map( + (k, e) => MapEntry(_$ActivityTypeEnumMap[k]!, e.map((k, e) => MapEntry(k.toString(), e))), + ), +}; const _$ActivityTypeEnumMap = { ActivityType.IN_VEHICLE: 'IN_VEHICLE', @@ -56,87 +48,61 @@ const _$ActivityTypeEnumMap = { ActivityType.UNKNOWN: 'UNKNOWN', }; -WeeklyMobility _$WeeklyMobilityFromJson(Map json) => - WeeklyMobility() - ..weekMobility = (json['weekMobility'] as Map).map( - (k, e) => MapEntry( - int.parse(k), - DailyMobility.fromJson(e as Map), - ), - ); +WeeklyMobility _$WeeklyMobilityFromJson(Map json) => WeeklyMobility() + ..weekMobility = (json['weekMobility'] as Map).map( + (k, e) => MapEntry(int.parse(k), DailyMobility.fromJson(e as Map)), + ); -Map _$WeeklyMobilityToJson(WeeklyMobility instance) => - { - 'weekMobility': instance.weekMobility.map( - (k, e) => MapEntry(k.toString(), e), - ), - }; - -DailyMobility _$DailyMobilityFromJson(Map json) => - DailyMobility( - (json['weekday'] as num).toInt(), - (json['places'] as num).toInt(), - (json['homeStay'] as num).toInt(), - (json['distance'] as num).toDouble(), - ); - -Map _$DailyMobilityToJson(DailyMobility instance) => - { - 'weekday': instance.weekday, - 'places': instance.places, - 'homeStay': instance.homeStay, - 'distance': instance.distance, - }; +Map _$WeeklyMobilityToJson(WeeklyMobility instance) => { + 'weekMobility': instance.weekMobility.map((k, e) => MapEntry(k.toString(), e)), +}; + +DailyMobility _$DailyMobilityFromJson(Map json) => DailyMobility( + (json['weekday'] as num).toInt(), + (json['places'] as num).toInt(), + (json['homeStay'] as num).toInt(), + (json['distance'] as num).toDouble(), +); + +Map _$DailyMobilityToJson(DailyMobility instance) => { + 'weekday': instance.weekday, + 'places': instance.places, + 'homeStay': instance.homeStay, + 'distance': instance.distance, +}; WeeklySteps _$WeeklyStepsFromJson(Map json) => WeeklySteps() ..weeklySteps = (json['weeklySteps'] as Map).map( (k, e) => MapEntry(int.parse(k), (e as num).toInt()), ); -Map _$WeeklyStepsToJson( - WeeklySteps instance, -) => { +Map _$WeeklyStepsToJson(WeeklySteps instance) => { 'weeklySteps': instance.weeklySteps.map((k, e) => MapEntry(k.toString(), e)), }; -HourlyHeartRate _$HourlyHeartRateFromJson(Map json) => - HourlyHeartRate() - ..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, - }; - -HeartRateMinMaxPrHour _$HeartRateMinMaxPrHourFromJson( - Map json, -) => HeartRateMinMaxPrHour( - (json['min'] as num?)?.toDouble(), - (json['max'] as num?)?.toDouble(), -); +HourlyHeartRate _$HourlyHeartRateFromJson(Map json) => HourlyHeartRate() + ..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, +}; -Map _$HeartRateMinMaxPrHourToJson( - HeartRateMinMaxPrHour instance, -) => {'min': ?instance.min, 'max': ?instance.max}; +HeartRateMinMaxPrHour _$HeartRateMinMaxPrHourFromJson(Map json) => + HeartRateMinMaxPrHour((json['min'] as num?)?.toDouble(), (json['max'] as num?)?.toDouble()); + +Map _$HeartRateMinMaxPrHourToJson(HeartRateMinMaxPrHour instance) => { + 'min': ?instance.min, + 'max': ?instance.max, +}; diff --git a/lib/ui/cards/heart_rate_card.dart b/lib/ui/cards/heart_rate_card.dart index 143e4ffd..5302590e 100644 --- a/lib/ui/cards/heart_rate_card.dart +++ b/lib/ui/cards/heart_rate_card.dart @@ -37,7 +37,10 @@ class HeartRateCardWidgetState extends State { children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, - children: [Expanded(child: _rangeSummary(colors)), _rangePicker(colors)], + children: [ + Expanded(child: _rangeSummary(colors)), + _rangePicker(colors), + ], ), const SizedBox(height: 16), SizedBox(height: 200, child: _chart(colors)), @@ -81,10 +84,7 @@ class HeartRateCardWidgetState extends State { 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), - ), + Text(locale.translate('cards.heartrate.bpm'), style: fs12fw600.copyWith(color: colors.grey600)), ], ], ), @@ -134,17 +134,11 @@ class HeartRateCardWidgetState extends State { 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), - ), + 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), - ), + Text(locale.translate('cards.heartrate.bpm'), style: fs12fw600.copyWith(color: colors.grey600)), ], ); } @@ -214,7 +208,12 @@ class HeartRateCardWidgetState extends State { sideTitles: SideTitles(showTitles: true, reservedSize: 24, getTitlesWidget: _bottomTitle), ), rightTitles: AxisTitles( - sideTitles: SideTitles(showTitles: true, reservedSize: 40, interval: interval, getTitlesWidget: _rightTitle), + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: interval, + getTitlesWidget: _rightTitle, + ), ), ), gridData: FlGridData( diff --git a/lib/ui/cards/study_progress_card.dart b/lib/ui/cards/study_progress_card.dart index f650cde5..b4c9b448 100644 --- a/lib/ui/cards/study_progress_card.dart +++ b/lib/ui/cards/study_progress_card.dart @@ -44,9 +44,7 @@ class _StudyProgressCardWidgetState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, - children: [ - for (final (index, state) in progress.indexed) _count(context, index, state), - ], + children: [for (final (index, state) in progress.indexed) _count(context, index, state)], ), ), SizedBox(width: 110, height: 110, child: _donut(progress)), diff --git a/lib/ui/cards/task_completion_card.dart b/lib/ui/cards/task_completion_card.dart index f839735a..f3235447 100644 --- a/lib/ui/cards/task_completion_card.dart +++ b/lib/ui/cards/task_completion_card.dart @@ -29,9 +29,7 @@ class _TaskCompletionCardState extends State { children: [ Row( children: [ - Expanded( - child: Text(locale.translate('cards.task_completion.title'), style: fs16fw700), - ), + Expanded(child: Text(locale.translate('cards.task_completion.title'), style: fs16fw700)), Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( diff --git a/lib/view_models/cards/activity_data_model.dart b/lib/view_models/cards/activity_data_model.dart index 048010ea..48b91eea 100644 --- a/lib/view_models/cards/activity_data_model.dart +++ b/lib/view_models/cards/activity_data_model.dart @@ -12,7 +12,8 @@ class ActivityCardViewModel extends SerializableViewModel { (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)); + !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); diff --git a/lib/view_models/cards/demo_chart_data.dart b/lib/view_models/cards/demo_chart_data.dart index 55224a6e..58708608 100644 --- a/lib/view_models/cards/demo_chart_data.dart +++ b/lib/view_models/cards/demo_chart_data.dart @@ -32,7 +32,16 @@ abstract final class DemoChartData { 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)]), + PolarHR( + samples: [ + PolarHRSample( + hr: _hrAt(low, high, dayScale, minute), + rrsMs: const [], + contactStatus: true, + contactStatusSupported: true, + ), + ], + ), _startOfWeek.add(Duration(days: dayOffset, hours: hour, minutes: minute)), ), ]; @@ -42,7 +51,10 @@ abstract final class DemoChartData { 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: type, confidence: 100), + _startOfWeek.add(Duration(days: dayOffset, hours: startHour)), + ), _measurement( Activity(type: ActivityType.STILL, confidence: 100), _startOfWeek.add(Duration(days: dayOffset, hours: endHour)), @@ -69,10 +81,30 @@ abstract final class DemoChartData { /// 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), + (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. diff --git a/lib/view_models/cards/heart_rate_data_model.dart b/lib/view_models/cards/heart_rate_data_model.dart index 2a6602b5..127ca17b 100644 --- a/lib/view_models/cards/heart_rate_data_model.dart +++ b/lib/view_models/cards/heart_rate_data_model.dart @@ -14,7 +14,9 @@ class HeartRateCardViewModel extends SerializableViewModel { /// 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); + 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; } diff --git a/test/heart_rate_data_model_test.mocks.dart b/test/heart_rate_data_model_test.mocks.dart index 99bc2a97..2c04d8ba 100644 --- a/test/heart_rate_data_model_test.mocks.dart +++ b/test/heart_rate_data_model_test.mocks.dart @@ -26,48 +26,33 @@ import 'package:permission_handler/permission_handler.dart' as _i4; // ignore_for_file: subtype_of_sealed_class // ignore_for_file: invalid_use_of_internal_member -class _FakeSmartphoneStudy_0 extends _i1.SmartFake - implements _i2.SmartphoneStudy { - _FakeSmartphoneStudy_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); +class _FakeSmartphoneStudy_0 extends _i1.SmartFake implements _i2.SmartphoneStudy { + _FakeSmartphoneStudy_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeSmartphoneDeploymentExecutor_1 extends _i1.SmartFake - implements _i2.SmartphoneDeploymentExecutor { - _FakeSmartphoneDeploymentExecutor_1( - Object parent, - Invocation parentInvocation, - ) : super(parent, parentInvocation); +class _FakeSmartphoneDeploymentExecutor_1 extends _i1.SmartFake implements _i2.SmartphoneDeploymentExecutor { + _FakeSmartphoneDeploymentExecutor_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } /// A class which mocks [SmartphoneStudyController]. /// /// See the documentation for Mockito's code generation for more information. -class MockSmartphoneStudyController extends _i1.Mock - implements _i2.SmartphoneStudyController { +class MockSmartphoneStudyController extends _i1.Mock implements _i2.SmartphoneStudyController { @override _i2.SmartphoneStudy get study => (super.noSuchMethod( Invocation.getter(#study), - returnValue: _FakeSmartphoneStudy_0( - this, - Invocation.getter(#study), - ), - returnValueForMissingStub: _FakeSmartphoneStudy_0( - this, - Invocation.getter(#study), - ), + returnValue: _FakeSmartphoneStudy_0(this, Invocation.getter(#study)), + returnValueForMissingStub: _FakeSmartphoneStudy_0(this, Invocation.getter(#study)), ) as _i2.SmartphoneStudy); @override - List<_i3.DeviceConfiguration<_i3.DeviceRegistration>> - get remainingDevicesToRegister => + List<_i3.DeviceConfiguration<_i3.DeviceRegistration>> get remainingDevicesToRegister => (super.noSuchMethod( Invocation.getter(#remainingDevicesToRegister), returnValue: <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], - returnValueForMissingStub: - <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], + returnValueForMissingStub: <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], ) as List<_i3.DeviceConfiguration<_i3.DeviceRegistration>>); @@ -84,14 +69,8 @@ class MockSmartphoneStudyController extends _i1.Mock _i2.SmartphoneDeploymentExecutor get executor => (super.noSuchMethod( Invocation.getter(#executor), - returnValue: _FakeSmartphoneDeploymentExecutor_1( - this, - Invocation.getter(#executor), - ), - returnValueForMissingStub: _FakeSmartphoneDeploymentExecutor_1( - this, - Invocation.getter(#executor), - ), + returnValue: _FakeSmartphoneDeploymentExecutor_1(this, Invocation.getter(#executor)), + returnValueForMissingStub: _FakeSmartphoneDeploymentExecutor_1(this, Invocation.getter(#executor)), ) as _i2.SmartphoneDeploymentExecutor); @@ -99,14 +78,8 @@ class MockSmartphoneStudyController extends _i1.Mock String get privacySchemaName => (super.noSuchMethod( Invocation.getter(#privacySchemaName), - returnValue: _i5.dummyValue( - this, - Invocation.getter(#privacySchemaName), - ), - returnValueForMissingStub: _i5.dummyValue( - this, - Invocation.getter(#privacySchemaName), - ), + returnValue: _i5.dummyValue(this, Invocation.getter(#privacySchemaName)), + returnValueForMissingStub: _i5.dummyValue(this, Invocation.getter(#privacySchemaName)), ) as String); @@ -129,9 +102,7 @@ class MockSmartphoneStudyController extends _i1.Mock as _i6.Stream<_i3.Measurement>); @override - _i6.Future tryRegisterConnectedDevice( - _i3.DeviceConfiguration<_i3.DeviceRegistration>? device, - ) => + _i6.Future tryRegisterConnectedDevice(_i3.DeviceConfiguration<_i3.DeviceRegistration>? device) => (super.noSuchMethod( Invocation.method(#tryRegisterConnectedDevice, [device]), returnValue: _i6.Future.value(), @@ -149,9 +120,7 @@ class MockSmartphoneStudyController extends _i1.Mock as _i6.Future); @override - _i6.Future tryUnregisterDisconnectedDevice( - _i3.DeviceConfiguration<_i3.DeviceRegistration>? device, - ) => + _i6.Future tryUnregisterDisconnectedDevice(_i3.DeviceConfiguration<_i3.DeviceRegistration>? device) => (super.noSuchMethod( Invocation.method(#tryUnregisterDisconnectedDevice, [device]), returnValue: _i6.Future.value(), @@ -160,9 +129,7 @@ class MockSmartphoneStudyController extends _i1.Mock as _i6.Future); @override - _i6.Future tryReregisterDevice( - _i3.DeviceConfiguration<_i3.DeviceRegistration>? device, - ) => + _i6.Future tryReregisterDevice(_i3.DeviceConfiguration<_i3.DeviceRegistration>? device) => (super.noSuchMethod( Invocation.method(#tryReregisterDevice, [device]), returnValue: _i6.Future.value(), @@ -180,26 +147,14 @@ class MockSmartphoneStudyController extends _i1.Mock as _i6.Future); @override - void restart() => super.noSuchMethod( - Invocation.method(#restart, []), - returnValueForMissingStub: null, - ); + void restart() => super.noSuchMethod(Invocation.method(#restart, []), returnValueForMissingStub: null); @override - void resume() => super.noSuchMethod( - Invocation.method(#resume, []), - returnValueForMissingStub: null, - ); + void resume() => super.noSuchMethod(Invocation.method(#resume, []), returnValueForMissingStub: null); @override - void pause() => super.noSuchMethod( - Invocation.method(#pause, []), - returnValueForMissingStub: null, - ); + void pause() => super.noSuchMethod(Invocation.method(#pause, []), returnValueForMissingStub: null); @override - void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); + void dispose() => super.noSuchMethod(Invocation.method(#dispose, []), returnValueForMissingStub: null); } diff --git a/test/services_test.mocks.dart b/test/services_test.mocks.dart index a11a816b..85b56352 100644 --- a/test/services_test.mocks.dart +++ b/test/services_test.mocks.dart @@ -30,52 +30,40 @@ import 'package:research_package/research_package.dart' as _i8; // ignore_for_file: invalid_use_of_internal_member class _FakeUri_0 extends _i1.SmartFake implements Uri { - _FakeUri_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeUri_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } class _FakeCarpApp_1 extends _i1.SmartFake implements _i2.CarpApp { - _FakeCarpApp_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeCarpApp_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeCarpAuthProperties_2 extends _i1.SmartFake - implements _i3.CarpAuthProperties { - _FakeCarpAuthProperties_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); +class _FakeCarpAuthProperties_2 extends _i1.SmartFake implements _i3.CarpAuthProperties { + _FakeCarpAuthProperties_2(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } class _FakeCarpUser_3 extends _i1.SmartFake implements _i3.CarpUser { - _FakeCarpUser_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeCarpUser_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } class _FakeAppTask_4 extends _i1.SmartFake implements _i4.AppTask { - _FakeAppTask_4(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeAppTask_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } class _FakeDateTime_5 extends _i1.SmartFake implements DateTime { - _FakeDateTime_5(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeDateTime_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } class _FakeAppTaskExecutor_6 extends _i1.SmartFake implements _i4.AppTaskExecutor { - _FakeAppTaskExecutor_6(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeAppTaskExecutor_6(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeBackgroundTaskExecutor_7 extends _i1.SmartFake - implements _i4.BackgroundTaskExecutor { - _FakeBackgroundTaskExecutor_7(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); +class _FakeBackgroundTaskExecutor_7 extends _i1.SmartFake implements _i4.BackgroundTaskExecutor { + _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); +class _FakeDeploymentService_8 extends _i1.SmartFake implements _i5.DeploymentService { + _FakeDeploymentService_8(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } /// A class which mocks [CarpBackend]. @@ -87,10 +75,7 @@ class MockCarpBackend extends _i1.Mock implements _i6.CarpBackend { (super.noSuchMethod( Invocation.getter(#uri), returnValue: _FakeUri_0(this, Invocation.getter(#uri)), - returnValueForMissingStub: _FakeUri_0( - this, - Invocation.getter(#uri), - ), + returnValueForMissingStub: _FakeUri_0(this, Invocation.getter(#uri)), ) as Uri); @@ -99,10 +84,7 @@ class MockCarpBackend extends _i1.Mock implements _i6.CarpBackend { (super.noSuchMethod( Invocation.getter(#authUri), returnValue: _FakeUri_0(this, Invocation.getter(#authUri)), - returnValueForMissingStub: _FakeUri_0( - this, - Invocation.getter(#authUri), - ), + returnValueForMissingStub: _FakeUri_0(this, Invocation.getter(#authUri)), ) as Uri); @@ -111,10 +93,7 @@ class MockCarpBackend extends _i1.Mock implements _i6.CarpBackend { (super.noSuchMethod( Invocation.getter(#app), returnValue: _FakeCarpApp_1(this, Invocation.getter(#app)), - returnValueForMissingStub: _FakeCarpApp_1( - this, - Invocation.getter(#app), - ), + returnValueForMissingStub: _FakeCarpApp_1(this, Invocation.getter(#app)), ) as _i2.CarpApp); @@ -122,24 +101,14 @@ class MockCarpBackend extends _i1.Mock implements _i6.CarpBackend { _i3.CarpAuthProperties get authProperties => (super.noSuchMethod( Invocation.getter(#authProperties), - returnValue: _FakeCarpAuthProperties_2( - this, - Invocation.getter(#authProperties), - ), - returnValueForMissingStub: _FakeCarpAuthProperties_2( - this, - Invocation.getter(#authProperties), - ), + returnValue: _FakeCarpAuthProperties_2(this, Invocation.getter(#authProperties)), + returnValueForMissingStub: _FakeCarpAuthProperties_2(this, Invocation.getter(#authProperties)), ) as _i3.CarpAuthProperties); @override bool get isAuthenticated => - (super.noSuchMethod( - Invocation.getter(#isAuthenticated), - returnValue: false, - returnValueForMissingStub: false, - ) + (super.noSuchMethod(Invocation.getter(#isAuthenticated), returnValue: false, returnValueForMissingStub: false) as bool); @override @@ -152,23 +121,15 @@ class MockCarpBackend extends _i1.Mock implements _i6.CarpBackend { as List<_i5.ActiveParticipationInvitation>); @override - set user(_i3.CarpUser? user) => super.noSuchMethod( - Invocation.setter(#user, user), - returnValueForMissingStub: null, - ); + set user(_i3.CarpUser? user) => super.noSuchMethod(Invocation.setter(#user, user), returnValueForMissingStub: null); @override set invitations(List<_i5.ActiveParticipationInvitation>? value) => - super.noSuchMethod( - Invocation.setter(#invitations, value), - returnValueForMissingStub: null, - ); + super.noSuchMethod(Invocation.setter(#invitations, value), returnValueForMissingStub: null); @override - set study(_i4.SmartphoneStudy? study) => super.noSuchMethod( - Invocation.setter(#study, study), - returnValueForMissingStub: null, - ); + set study(_i4.SmartphoneStudy? study) => + super.noSuchMethod(Invocation.setter(#study, study), returnValueForMissingStub: null); @override _i7.Future initialize() => @@ -201,9 +162,7 @@ class MockCarpBackend extends _i1.Mock implements _i6.CarpBackend { _i7.Future<_i3.CarpUser> refresh() => (super.noSuchMethod( Invocation.method(#refresh, []), - returnValue: _i7.Future<_i3.CarpUser>.value( - _FakeCarpUser_3(this, Invocation.method(#refresh, [])), - ), + returnValue: _i7.Future<_i3.CarpUser>.value(_FakeCarpUser_3(this, Invocation.method(#refresh, []))), returnValueForMissingStub: _i7.Future<_i3.CarpUser>.value( _FakeCarpUser_3(this, Invocation.method(#refresh, [])), ), @@ -223,39 +182,28 @@ class MockCarpBackend extends _i1.Mock implements _i6.CarpBackend { _i7.Future> getInvitations() => (super.noSuchMethod( Invocation.method(#getInvitations, []), - returnValue: - _i7.Future>.value( - <_i5.ActiveParticipationInvitation>[], - ), - returnValueForMissingStub: - _i7.Future>.value( - <_i5.ActiveParticipationInvitation>[], - ), + returnValue: _i7.Future>.value( + <_i5.ActiveParticipationInvitation>[], + ), + returnValueForMissingStub: _i7.Future>.value( + <_i5.ActiveParticipationInvitation>[], + ), ) as _i7.Future>); @override - _i7.Future<_i5.InformedConsentInput?> uploadInformedConsent( - _i8.RPTaskResult? consent, - ) => + _i7.Future<_i5.InformedConsentInput?> uploadInformedConsent(_i8.RPTaskResult? consent) => (super.noSuchMethod( Invocation.method(#uploadInformedConsent, [consent]), returnValue: _i7.Future<_i5.InformedConsentInput?>.value(), - returnValueForMissingStub: - _i7.Future<_i5.InformedConsentInput?>.value(), + returnValueForMissingStub: _i7.Future<_i5.InformedConsentInput?>.value(), ) as _i7.Future<_i5.InformedConsentInput?>); @override - _i7.Future<_i5.InformedConsentInput?>? getInformedConsentByRole( - String? studyDeploymentId, - String? role, - ) => + _i7.Future<_i5.InformedConsentInput?>? getInformedConsentByRole(String? studyDeploymentId, String? role) => (super.noSuchMethod( - Invocation.method(#getInformedConsentByRole, [ - studyDeploymentId, - role, - ]), + Invocation.method(#getInformedConsentByRole, [studyDeploymentId, role]), returnValueForMissingStub: null, ) as _i7.Future<_i5.InformedConsentInput?>?); @@ -267,34 +215,20 @@ class MockCarpBackend extends _i1.Mock implements _i6.CarpBackend { class MockAuthService extends _i1.Mock implements _i6.AuthService { @override bool get isAuthenticated => - (super.noSuchMethod( - Invocation.getter(#isAuthenticated), - returnValue: false, - returnValueForMissingStub: false, - ) + (super.noSuchMethod(Invocation.getter(#isAuthenticated), returnValue: false, returnValueForMissingStub: false) as bool); @override bool get isAnonymous => - (super.noSuchMethod( - Invocation.getter(#isAnonymous), - returnValue: false, - returnValueForMissingStub: false, - ) + (super.noSuchMethod(Invocation.getter(#isAnonymous), returnValue: false, returnValueForMissingStub: false) as bool); @override String get username => (super.noSuchMethod( Invocation.getter(#username), - returnValue: _i9.dummyValue( - this, - Invocation.getter(#username), - ), - returnValueForMissingStub: _i9.dummyValue( - this, - Invocation.getter(#username), - ), + returnValue: _i9.dummyValue(this, Invocation.getter(#username)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#username)), ) as String); @@ -302,14 +236,8 @@ class MockAuthService extends _i1.Mock implements _i6.AuthService { String get friendlyUsername => (super.noSuchMethod( Invocation.getter(#friendlyUsername), - returnValue: _i9.dummyValue( - this, - Invocation.getter(#friendlyUsername), - ), - returnValueForMissingStub: _i9.dummyValue( - this, - Invocation.getter(#friendlyUsername), - ), + returnValue: _i9.dummyValue(this, Invocation.getter(#friendlyUsername)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#friendlyUsername)), ) as String); @@ -318,10 +246,7 @@ class MockAuthService extends _i1.Mock implements _i6.AuthService { (super.noSuchMethod( Invocation.getter(#serverUri), returnValue: _FakeUri_0(this, Invocation.getter(#serverUri)), - returnValueForMissingStub: _FakeUri_0( - this, - Invocation.getter(#serverUri), - ), + returnValueForMissingStub: _FakeUri_0(this, Invocation.getter(#serverUri)), ) as Uri); @@ -347,14 +272,12 @@ class MockAuthService extends _i1.Mock implements _i6.AuthService { _i7.Future> getInvitations() => (super.noSuchMethod( Invocation.method(#getInvitations, []), - returnValue: - _i7.Future>.value( - <_i5.ActiveParticipationInvitation>[], - ), - returnValueForMissingStub: - _i7.Future>.value( - <_i5.ActiveParticipationInvitation>[], - ), + returnValue: _i7.Future>.value( + <_i5.ActiveParticipationInvitation>[], + ), + returnValueForMissingStub: _i7.Future>.value( + <_i5.ActiveParticipationInvitation>[], + ), ) as _i7.Future>); @@ -436,10 +359,7 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { (super.noSuchMethod( Invocation.getter(#task), returnValue: _FakeAppTask_4(this, Invocation.getter(#task)), - returnValueForMissingStub: _FakeAppTask_4( - this, - Invocation.getter(#task), - ), + returnValueForMissingStub: _FakeAppTask_4(this, Invocation.getter(#task)), ) as _i4.AppTask); @@ -448,10 +368,7 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { (super.noSuchMethod( Invocation.getter(#id), returnValue: _i9.dummyValue(this, Invocation.getter(#id)), - returnValueForMissingStub: _i9.dummyValue( - this, - Invocation.getter(#id), - ), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#id)), ) as String); @@ -460,10 +377,7 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { (super.noSuchMethod( Invocation.getter(#type), returnValue: _i9.dummyValue(this, Invocation.getter(#type)), - returnValueForMissingStub: _i9.dummyValue( - this, - Invocation.getter(#type), - ), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#type)), ) as String); @@ -472,10 +386,7 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { (super.noSuchMethod( Invocation.getter(#name), returnValue: _i9.dummyValue(this, Invocation.getter(#name)), - returnValueForMissingStub: _i9.dummyValue( - this, - Invocation.getter(#name), - ), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#name)), ) as String); @@ -483,14 +394,8 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { String get title => (super.noSuchMethod( Invocation.getter(#title), - returnValue: _i9.dummyValue( - this, - Invocation.getter(#title), - ), - returnValueForMissingStub: _i9.dummyValue( - this, - Invocation.getter(#title), - ), + returnValue: _i9.dummyValue(this, Invocation.getter(#title)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#title)), ) as String); @@ -498,14 +403,8 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { String get description => (super.noSuchMethod( Invocation.getter(#description), - returnValue: _i9.dummyValue( - this, - Invocation.getter(#description), - ), - returnValueForMissingStub: _i9.dummyValue( - this, - Invocation.getter(#description), - ), + returnValue: _i9.dummyValue(this, Invocation.getter(#description)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#description)), ) as String); @@ -513,24 +412,14 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { String get instructions => (super.noSuchMethod( Invocation.getter(#instructions), - returnValue: _i9.dummyValue( - this, - Invocation.getter(#instructions), - ), - returnValueForMissingStub: _i9.dummyValue( - this, - Invocation.getter(#instructions), - ), + returnValue: _i9.dummyValue(this, Invocation.getter(#instructions)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#instructions)), ) as String); @override bool get notification => - (super.noSuchMethod( - Invocation.getter(#notification), - returnValue: false, - returnValueForMissingStub: false, - ) + (super.noSuchMethod(Invocation.getter(#notification), returnValue: false, returnValueForMissingStub: false) as bool); @override @@ -538,10 +427,7 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { (super.noSuchMethod( Invocation.getter(#triggerTime), returnValue: _FakeDateTime_5(this, Invocation.getter(#triggerTime)), - returnValueForMissingStub: _FakeDateTime_5( - this, - Invocation.getter(#triggerTime), - ), + returnValueForMissingStub: _FakeDateTime_5(this, Invocation.getter(#triggerTime)), ) as DateTime); @@ -550,10 +436,7 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { (super.noSuchMethod( Invocation.getter(#enqueued), returnValue: _FakeDateTime_5(this, Invocation.getter(#enqueued)), - returnValueForMissingStub: _FakeDateTime_5( - this, - Invocation.getter(#enqueued), - ), + returnValueForMissingStub: _FakeDateTime_5(this, Invocation.getter(#enqueued)), ) as DateTime); @@ -568,11 +451,7 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { @override bool get availableForUser => - (super.noSuchMethod( - Invocation.getter(#availableForUser), - returnValue: false, - returnValueForMissingStub: false, - ) + (super.noSuchMethod(Invocation.getter(#availableForUser), returnValue: false, returnValueForMissingStub: false) as bool); @override @@ -597,14 +476,8 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { _i4.AppTaskExecutor<_i4.AppTask> get appTaskExecutor => (super.noSuchMethod( Invocation.getter(#appTaskExecutor), - returnValue: _FakeAppTaskExecutor_6<_i4.AppTask>( - this, - Invocation.getter(#appTaskExecutor), - ), - returnValueForMissingStub: _FakeAppTaskExecutor_6<_i4.AppTask>( - this, - Invocation.getter(#appTaskExecutor), - ), + returnValue: _FakeAppTaskExecutor_6<_i4.AppTask>(this, Invocation.getter(#appTaskExecutor)), + returnValueForMissingStub: _FakeAppTaskExecutor_6<_i4.AppTask>(this, Invocation.getter(#appTaskExecutor)), ) as _i4.AppTaskExecutor<_i4.AppTask>); @@ -612,92 +485,54 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { _i4.BackgroundTaskExecutor get backgroundTaskExecutor => (super.noSuchMethod( Invocation.getter(#backgroundTaskExecutor), - returnValue: _FakeBackgroundTaskExecutor_7( - this, - Invocation.getter(#backgroundTaskExecutor), - ), - returnValueForMissingStub: _FakeBackgroundTaskExecutor_7( - this, - Invocation.getter(#backgroundTaskExecutor), - ), + returnValue: _FakeBackgroundTaskExecutor_7(this, Invocation.getter(#backgroundTaskExecutor)), + returnValueForMissingStub: _FakeBackgroundTaskExecutor_7(this, Invocation.getter(#backgroundTaskExecutor)), ) as _i4.BackgroundTaskExecutor); @override bool get hasWidget => - (super.noSuchMethod( - Invocation.getter(#hasWidget), - returnValue: false, - returnValueForMissingStub: false, - ) - as bool); + (super.noSuchMethod(Invocation.getter(#hasWidget), returnValue: false, returnValueForMissingStub: false) as bool); @override - set id(String? value) => super.noSuchMethod( - Invocation.setter(#id, value), - returnValueForMissingStub: null, - ); + set id(String? value) => super.noSuchMethod(Invocation.setter(#id, value), returnValueForMissingStub: null); @override - set triggerTime(DateTime? value) => super.noSuchMethod( - Invocation.setter(#triggerTime, value), - returnValueForMissingStub: null, - ); + set triggerTime(DateTime? value) => + super.noSuchMethod(Invocation.setter(#triggerTime, value), returnValueForMissingStub: null); @override - set enqueued(DateTime? value) => super.noSuchMethod( - Invocation.setter(#enqueued, value), - returnValueForMissingStub: null, - ); + set enqueued(DateTime? value) => + super.noSuchMethod(Invocation.setter(#enqueued, value), returnValueForMissingStub: null); @override - set doneTime(DateTime? value) => super.noSuchMethod( - Invocation.setter(#doneTime, value), - returnValueForMissingStub: null, - ); + set doneTime(DateTime? value) => + super.noSuchMethod(Invocation.setter(#doneTime, value), returnValueForMissingStub: null); @override - set state(_i4.UserTaskState? state) => super.noSuchMethod( - Invocation.setter(#state, state), - returnValueForMissingStub: null, - ); + set state(_i4.UserTaskState? state) => + super.noSuchMethod(Invocation.setter(#state, state), returnValueForMissingStub: null); @override - set hasNotificationBeenCreated(bool? value) => super.noSuchMethod( - Invocation.setter(#hasNotificationBeenCreated, value), - returnValueForMissingStub: null, - ); + set hasNotificationBeenCreated(bool? value) => + super.noSuchMethod(Invocation.setter(#hasNotificationBeenCreated, value), returnValueForMissingStub: null); @override set backgroundTaskExecutor(_i4.BackgroundTaskExecutor? value) => - super.noSuchMethod( - Invocation.setter(#backgroundTaskExecutor, value), - returnValueForMissingStub: null, - ); + super.noSuchMethod(Invocation.setter(#backgroundTaskExecutor, value), returnValueForMissingStub: null); @override - set result(_i5.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, - ); + void onStart() => super.noSuchMethod(Invocation.method(#onStart, []), returnValueForMissingStub: null); @override - void onCancel({bool? dequeue = false}) => super.noSuchMethod( - Invocation.method(#onCancel, [], {#dequeue: dequeue}), - returnValueForMissingStub: null, - ); + void onCancel({bool? dequeue = false}) => + super.noSuchMethod(Invocation.method(#onCancel, [], {#dequeue: dequeue}), returnValueForMissingStub: null); @override - void onExpired() => super.noSuchMethod( - Invocation.method(#onExpired, []), - returnValueForMissingStub: null, - ); + void onExpired() => super.noSuchMethod(Invocation.method(#onExpired, []), returnValueForMissingStub: null); @override void onDone({bool? dequeue = false, _i5.Data? result}) => super.noSuchMethod( @@ -706,10 +541,7 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { ); @override - void onNotification() => super.noSuchMethod( - Invocation.method(#onNotification, []), - returnValueForMissingStub: null, - ); + void onNotification() => super.noSuchMethod(Invocation.method(#onNotification, []), returnValueForMissingStub: null); } /// A class which mocks [StudyService]. @@ -720,33 +552,18 @@ class MockStudyService extends _i1.Mock implements _i6.StudyService { _i5.DeploymentService get deploymentService => (super.noSuchMethod( Invocation.getter(#deploymentService), - returnValue: _FakeDeploymentService_8( - this, - Invocation.getter(#deploymentService), - ), - returnValueForMissingStub: _FakeDeploymentService_8( - this, - 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); + (super.noSuchMethod(Invocation.getter(#hasStudy), returnValue: false, returnValueForMissingStub: false) as bool); @override bool get isDeployed => - (super.noSuchMethod( - Invocation.getter(#isDeployed), - returnValue: false, - returnValueForMissingStub: false, - ) + (super.noSuchMethod(Invocation.getter(#isDeployed), returnValue: false, returnValueForMissingStub: false) as bool); @override @@ -760,12 +577,7 @@ class MockStudyService extends _i1.Mock implements _i6.StudyService { @override bool get isRunning => - (super.noSuchMethod( - Invocation.getter(#isRunning), - returnValue: false, - returnValueForMissingStub: false, - ) - as bool); + (super.noSuchMethod(Invocation.getter(#isRunning), returnValue: false, returnValueForMissingStub: false) as bool); @override Iterable<_i6.DeviceViewModel> get deploymentDevices => @@ -777,18 +589,15 @@ class MockStudyService extends _i1.Mock implements _i6.StudyService { as Iterable<_i6.DeviceViewModel>); @override - set study(_i4.SmartphoneStudy? study) => super.noSuchMethod( - Invocation.setter(#study, study), - returnValueForMissingStub: null, - ); + set study(_i4.SmartphoneStudy? study) => + super.noSuchMethod(Invocation.setter(#study, study), returnValueForMissingStub: null); @override _i7.Future<_i5.StudyDeploymentStatus?> refreshDeploymentStatus() => (super.noSuchMethod( Invocation.method(#refreshDeploymentStatus, []), returnValue: _i7.Future<_i5.StudyDeploymentStatus?>.value(), - returnValueForMissingStub: - _i7.Future<_i5.StudyDeploymentStatus?>.value(), + returnValueForMissingStub: _i7.Future<_i5.StudyDeploymentStatus?>.value(), ) as _i7.Future<_i5.StudyDeploymentStatus?>); @@ -829,65 +638,40 @@ class MockStudyService extends _i1.Mock implements _i6.StudyService { as _i7.Future); @override - void addMeasurement(_i5.Measurement? measurement) => super.noSuchMethod( - Invocation.method(#addMeasurement, [measurement]), - returnValueForMissingStub: null, - ); + void addMeasurement(_i5.Measurement? measurement) => + super.noSuchMethod(Invocation.method(#addMeasurement, [measurement]), returnValueForMissingStub: null); @override bool hasMeasures() => - (super.noSuchMethod( - Invocation.method(#hasMeasures, []), - returnValue: false, - returnValueForMissingStub: false, - ) + (super.noSuchMethod(Invocation.method(#hasMeasures, []), returnValue: false, returnValueForMissingStub: false) as bool); @override bool hasMeasure(String? type) => - (super.noSuchMethod( - Invocation.method(#hasMeasure, [type]), - returnValue: false, - returnValueForMissingStub: false, - ) + (super.noSuchMethod(Invocation.method(#hasMeasure, [type]), returnValue: false, returnValueForMissingStub: false) as bool); @override bool hasUserTasks() => - (super.noSuchMethod( - Invocation.method(#hasUserTasks, []), - returnValue: false, - returnValueForMissingStub: false, - ) + (super.noSuchMethod(Invocation.method(#hasUserTasks, []), returnValue: false, returnValueForMissingStub: false) as bool); @override void translateProtocol([_i8.RPLocalizations? localization]) => - super.noSuchMethod( - Invocation.method(#translateProtocol, [localization]), - returnValueForMissingStub: null, - ); + super.noSuchMethod(Invocation.method(#translateProtocol, [localization]), returnValueForMissingStub: null); @override - _i7.Future> - getParticipantDataListFromDeployment() => + _i7.Future> getParticipantDataListFromDeployment() => (super.noSuchMethod( Invocation.method(#getParticipantDataListFromDeployment, []), - returnValue: _i7.Future>.value( - <_i5.ParticipantData>[], - ), - returnValueForMissingStub: - _i7.Future>.value( - <_i5.ParticipantData>[], - ), + returnValue: _i7.Future>.value(<_i5.ParticipantData>[]), + returnValueForMissingStub: _i7.Future>.value(<_i5.ParticipantData>[]), ) as _i7.Future>); @override - void setParticipantData(Map? data) => super.noSuchMethod( - Invocation.method(#setParticipantData, [data]), - returnValueForMissingStub: null, - ); + void setParticipantData(Map? data) => + super.noSuchMethod(Invocation.method(#setParticipantData, [data]), returnValueForMissingStub: null); @override _i7.Future deployLocalProtocol() =>