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 00000000..6b6b8b8a Binary files /dev/null and b/android/app/src/main/res/drawable/ic_launcher.png differ 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 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..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'; @@ -101,6 +100,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,11 +146,11 @@ part 'ui/widgets/location_usage_dialog.dart'; part 'ui/cards/activity_card.dart'; part 'ui/cards/anonymous_card.dart'; +part 'ui/cards/connections_status_card.dart'; part 'ui/cards/distance_card.dart'; part 'ui/cards/heart_rate_card.dart'; part 'ui/cards/media_card.dart'; part 'ui/cards/mobility_card.dart'; -part 'ui/cards/scoreboard_card.dart'; part 'ui/cards/steps_card.dart'; part 'ui/cards/study_progress_card.dart'; part 'ui/cards/survey_card.dart'; 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/cards/scoreboard_card.dart b/lib/ui/cards/scoreboard_card.dart deleted file mode 100644 index 974d8897..00000000 --- a/lib/ui/cards/scoreboard_card.dart +++ /dev/null @@ -1,163 +0,0 @@ -part of carp_study_app; - -class ScoreboardCard extends StatefulWidget { - final TaskListPageViewModel model; - const ScoreboardCard(this.model, {super.key}); - @override - ScoreboardCardState createState() => ScoreboardCardState(); -} - -class ScoreboardCardState extends State { - @override - Widget build(BuildContext context) { - RPLocalizations locale = RPLocalizations.of(context)!; - - return SliverPersistentHeader( - pinned: false, - delegate: ScoreboardPersistentHeaderDelegate(model: widget.model, locale: locale, minExtent: 40, maxExtent: 110), - ); - } -} - -/// Make a [SliverPersistentHeaderDelegate] to use in a [SliverPersistentHeader] widget, -/// that can be used in a [CustomScrollView]. -/// This is used in the [StudyPage] to make the header of the page. -/// The delegate should retract from 110px to 40px when scrolling down. -/// The animation should be simple and linear. A stretched header does not do anything. -class ScoreboardPersistentHeaderDelegate extends SliverPersistentHeaderDelegate { - TaskListPageViewModel model; - RPLocalizations locale; - @override - final double minExtent; - @override - final double maxExtent; - - ScoreboardPersistentHeaderDelegate({ - required this.minExtent, - required this.maxExtent, - required this.model, - required this.locale, - }); - - @override - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { - double height = 110; - - double offsetForShrink = 50; - - List childrenDays = [ - Text( - model.daysInStudy.toString(), - style: fs36fw800.copyWith( - fontSize: calculateScrollAwareSizing(shrinkOffset, fs20fw800.fontSize!, fs36fw800.fontSize!), - color: Theme.of(context).extension()!.grey900, - ), - ), - if (shrinkOffset < offsetForShrink) - Text( - locale.translate('cards.scoreboard.days'), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey900), - ), - if (shrinkOffset > offsetForShrink) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - locale.translate('cards.scoreboard.days-short'), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey900), - ), - ), - ]; - - List childrenTasks = [ - Text( - model.taskCompleted.toString(), - style: fs36fw800.copyWith( - fontSize: calculateScrollAwareSizing(shrinkOffset, fs20fw800.fontSize!, fs36fw800.fontSize!), - color: Theme.of(context).extension()!.primary, - ), - ), - if (shrinkOffset < offsetForShrink) - Text( - locale.translate('cards.scoreboard.tasks'), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.primary), - ), - if (shrinkOffset > offsetForShrink) - Expanded( - flex: 0, - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - locale.translate('cards.scoreboard.tasks-short'), - style: fs12fw700.copyWith(color: Theme.of(context).extension()!.primary), - ), - ), - ), - ]; - - return Container( - height: height, - decoration: BoxDecoration( - color: Theme.of(context).extension()!.white, - borderRadius: BorderRadius.circular(8), // Rounded corners - ), - child: StreamBuilder( - stream: model.userTaskEvents, - builder: (context, snapshot) { - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: shrinkOffset < offsetForShrink - ? Column(mainAxisAlignment: MainAxisAlignment.center, children: childrenDays) - : Row(mainAxisAlignment: MainAxisAlignment.center, children: childrenDays), - ), - // A vertical divider line with rounded corners that spans from 10% to 90% of the height - Expanded( - flex: 0, - child: Container( - height: calculateScrollAwareSizing(shrinkOffset, minExtent * 0.6, maxExtent * 0.6), - width: 2, - decoration: BoxDecoration( - color: Theme.of(context).dividerColor, - borderRadius: BorderRadius.circular(100), - ), - ), - ), - Expanded( - child: shrinkOffset < offsetForShrink - ? Column(mainAxisAlignment: MainAxisAlignment.center, children: childrenTasks) - : Row(mainAxisAlignment: MainAxisAlignment.center, children: childrenTasks), - ), - ], - ); - }, - ), - ); - } - - // A simple function that returns the font size from the scoreNumberStyle, but increasingly smaller when scrolling down. - // Also used for the size of the divider in the middle - double calculateScrollAwareSizing(double shrinkOffset, double minSize, double maxSize) { - // Calculate the normalized shrinkOffset value in the range [0, 1] - double normalizedShrinkOffset = shrinkOffset / maxExtent; - - // Calculate the font size using linear interpolation - double size = maxSize - normalizedShrinkOffset * (maxSize - minSize); - - // Return the calculated font size - return size; - } - - @override - bool shouldRebuild(ScoreboardPersistentHeaderDelegate oldDelegate) { - return true; - } - - @override - FloatingHeaderSnapConfiguration get snapConfiguration => - FloatingHeaderSnapConfiguration(curve: Curves.linear, duration: const Duration(milliseconds: 100)); - - @override - OverScrollHeaderStretchConfiguration get stretchConfiguration => OverScrollHeaderStretchConfiguration(); -} diff --git a/lib/ui/cards/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/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/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..4bedf270 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( @@ -272,7 +251,19 @@ class DeviceListPageState extends State { if (!(await service.deviceManager.hasPermissions())) { if (service.type == HealthService.DEVICE_TYPE) { - Navigator.push(context, MaterialPageRoute(builder: (context) => HealthServiceConnectPage())); + Navigator.of( + context, + rootNavigator: true, + ).push(MaterialPageRoute(builder: (context) => HealthServiceConnectPage())); + } else if (service.type == LocationService.DEVICE_TYPE) { + final status = await Permission.locationWhenInUse.request(); + // Permanently denied/restricted: the OS won't prompt again, so send the + // user to Settings instead of silently doing nothing. + if (status.isPermanentlyDenied || status.isRestricted) { + await openAppSettings(); + return; + } + if (!status.isGranted) return; } else { await service.deviceManager.requestPermissions(); } @@ -306,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/pages/home_page.dart b/lib/ui/pages/home_page.dart index 31f21a0d..938efeb5 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -1,116 +1,256 @@ 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), + 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)), + ), + SurveyCard(model.surveys, showTitle: false), + ], + 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); + // 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(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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)), + ], + ), + ), + ], + ), + ), + ); } - @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. +/// A slim text row with a "Get" button that opens the store. +class AppUpdateCard extends StatelessWidget { + final HomePageViewModel model; + const AppUpdateCard({required this.model, super.key}); @override Widget build(BuildContext context) { - RPLocalizations locale = RPLocalizations.of(context)!; + if (!model.appUpdateAvailable) return const SizedBox.shrink(); + final colors = Theme.of(context).extension()!; + final locale = RPLocalizations.of(context)!; - return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, - body: SafeArea(child: widget.child), - bottomNavigationBar: BottomNavigationBar( - backgroundColor: Theme.of(context).extension()!.white, - type: BottomNavigationBarType.fixed, - selectedItemColor: Theme.of(context).extension()!.primary, - items: [ - BottomNavigationBarItem( - icon: const Icon(Icons.announcement), - label: locale.translate('app_home.nav_bar_item.about'), - activeIcon: const Icon(Icons.announcement), - ), - BottomNavigationBarItem( - icon: const Icon(Icons.playlist_add_check), - label: locale.translate('app_home.nav_bar_item.tasks'), - activeIcon: const Icon(Icons.playlist_add_check), - ), - BottomNavigationBarItem( - icon: const Icon(Icons.leaderboard), - label: locale.translate('app_home.nav_bar_item.data'), - activeIcon: const Icon(Icons.leaderboard), - ), - BottomNavigationBarItem( - icon: const Icon(Icons.devices_other), - label: locale.translate('app_home.nav_bar_item.devices'), - activeIcon: const Icon(Icons.devices_other), - ), - ], - currentIndex: _calculateSelectedIndex(context), - onTap: (int idx) => _onItemTapped(idx, context), + return StudiesMaterial( + backgroundColor: colors.grey50!, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Row( + children: [ + Expanded( + child: Text( + locale.translate('pages.about.app_update'), + style: fs14fw600.copyWith(color: colors.grey900), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: model.openAppStore, + style: FilledButton.styleFrom( + backgroundColor: colors.primary, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 20), + ), + child: const Text('Get'), + ), + ], + ), ), ); } +} - 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; - } +/// 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}); - 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; - } + 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) { + 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: 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/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/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/ui/widgets/carp_app_bar.dart b/lib/ui/widgets/carp_app_bar.dart index 38719d87..e47e7f0e 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}); @@ -23,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()))); }, ), ], 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/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..38b78104 100644 --- a/lib/view_models/home_page_model.dart +++ b/lib/view_models/home_page_model.dart @@ -1,24 +1,130 @@ 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; + + /// 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 []; + 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; + + /// 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 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()); + 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 + // 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 +136,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(); + } } 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 ?? ''; 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; } 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() {}